1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-07 02:28:54 +08:00

9 Commits

Author SHA1 Message Date
lana-k
0a8c09b58d #127 fix for new inquiry 2025-10-08 21:04:17 +02:00
lana-k
931cf380bc #127 tests 2025-10-08 19:39:56 +02:00
lana-k
f0f96ac663 tests 2025-10-05 20:59:34 +02:00
lana-k
45530cc9d6 add save as event 2025-10-05 14:27:50 +02:00
lana-k
6fbf75b601 fix tests 2025-10-03 22:13:33 +02:00
lana-k
d3fbf08569 #31 fix deleting inquiry 2025-09-29 21:17:36 +02:00
lana-k
be6a19a30f #127 fix copy to clipboard 2025-09-28 22:11:18 +02:00
lana-k
07d7a9d54b #31 handle concurrent saving 2025-09-27 21:59:32 +02:00
lana-k
cdd925b8af #16 save as 2025-09-27 17:01:50 +02:00
20 changed files with 1197 additions and 158 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "sqliteviz", "name": "sqliteviz",
"version": "0.26.1", "version": "0.27.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -1,16 +1,13 @@
<template> <template>
<div id="app"> <div id="app">
<router-view /> <router-view />
<modals-container />
</div> </div>
</template> </template>
<script> <script>
import storedInquiries from '@/lib/storedInquiries' import storedInquiries from '@/lib/storedInquiries'
import { ModalsContainer } from 'vue-final-modal'
export default { export default {
components: { ModalsContainer },
computed: { computed: {
inquiries() { inquiries() {
return this.$store.state.inquiries return this.$store.state.inquiries
@@ -26,6 +23,11 @@ export default {
}, },
created() { created() {
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries()) this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
addEventListener('storage', event => {
if (event.key === storedInquiries.myInquiriesKey) {
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
}
})
} }
} }
</script> </script>

View File

@@ -1,14 +1,15 @@
<template> <template>
<modal <modal
:modalId="name" v-model="show"
class="dialog" class="dialog"
:clickToClose="false" :clickToClose="false"
:contentTransition="{ name: 'loading-dialog' }" :contentTransition="{ name: 'loading-dialog' }"
:overlayTransition="{ name: 'loading-dialog' }" :overlayTransition="{ name: 'loading-dialog' }"
@update:modelValue="$emit('update:modelValue', $event)"
> >
<div class="dialog-header"> <div class="dialog-header">
{{ title }} {{ title }}
<close-icon :disabled="loading" @click="$emit('cancel')" /> <close-icon :disabled="loading" @click="cancel" />
</div> </div>
<div class="dialog-body"> <div class="dialog-body">
<div v-if="loading" class="loading-dialog-body"> <div v-if="loading" class="loading-dialog-body">
@@ -28,7 +29,7 @@
class="secondary" class="secondary"
type="button" type="button"
:disabled="loading" :disabled="loading"
@click="$emit('cancel')" @click="cancel"
> >
Cancel Cancel
</button> </button>
@@ -52,24 +53,33 @@ export default {
name: 'LoadingDialog', name: 'LoadingDialog',
components: { LoadingIndicator, CloseIcon }, components: { LoadingIndicator, CloseIcon },
props: { props: {
modelValue: Boolean,
loadingMsg: String, loadingMsg: String,
successMsg: String, successMsg: String,
actionBtnName: String, actionBtnName: String,
name: String,
title: String, title: String,
loading: Boolean loading: Boolean
}, },
emits: ['cancel', 'action'], data() {
return {
show: this.modelValue
}
},
emits: ['cancel', 'action', 'update:modelValue'],
watch: { watch: {
modelValue() {
this.show = this.modelValue
},
loading() { loading() {
if (this.loading) { if (this.loading) {
this.$modal.show(this.name) this.$emit('update:modelValue', true)
} }
} }
}, },
methods: { methods: {
cancel() { cancel() {
this.$emit('cancel') this.$emit('cancel')
this.$emit('update:modelValue', false)
} }
} }
} }

View File

@@ -4,11 +4,13 @@ import events from '@/lib/utils/events'
import migration from './_migrations' import migration from './_migrations'
const migrate = migration._migrate const migrate = migration._migrate
const myInquiriesKey = 'myInquiries'
export default { export default {
version: 2, version: 2,
myInquiriesKey,
getStoredInquiries() { getStoredInquiries() {
let myInquiries = JSON.parse(localStorage.getItem('myInquiries')) let myInquiries = JSON.parse(localStorage.getItem(myInquiriesKey))
if (!myInquiries) { if (!myInquiries) {
const oldInquiries = localStorage.getItem('myQueries') const oldInquiries = localStorage.getItem('myQueries')
if (oldInquiries) { if (oldInquiries) {
@@ -26,7 +28,8 @@ export default {
const newInquiry = JSON.parse(JSON.stringify(baseInquiry)) const newInquiry = JSON.parse(JSON.stringify(baseInquiry))
newInquiry.name = newInquiry.name + ' Copy' newInquiry.name = newInquiry.name + ' Copy'
newInquiry.id = nanoid() newInquiry.id = nanoid()
newInquiry.createdAt = new Date() newInquiry.createdAt = new Date().toJSON()
newInquiry.updatedAt = new Date().toJSON()
delete newInquiry.isPredefined delete newInquiry.isPredefined
return newInquiry return newInquiry
@@ -38,7 +41,7 @@ export default {
updateStorage(inquiries) { updateStorage(inquiries) {
localStorage.setItem( localStorage.setItem(
'myInquiries', myInquiriesKey,
JSON.stringify({ version: this.version, inquiries }) JSON.stringify({ version: this.version, inquiries })
) )
}, },

View File

@@ -28,6 +28,7 @@ export default class Tab {
this.isSaved = !!inquiry.id this.isSaved = !!inquiry.id
this.state = state this.state = state
this.updatedAt = inquiry.updatedAt
} }
async execute() { async execute() {

View File

@@ -17,28 +17,33 @@ export default {
}, },
async saveInquiry({ state }, { inquiryTab, newName }) { async saveInquiry({ state }, { inquiryTab, newName }) {
const value = { const value = {
id: inquiryTab.isPredefined ? nanoid() : inquiryTab.id, id: inquiryTab.isPredefined || newName ? nanoid() : inquiryTab.id,
query: inquiryTab.query, query: inquiryTab.query,
viewType: inquiryTab.dataView.mode, viewType: inquiryTab.dataView.mode,
viewOptions: inquiryTab.dataView.getOptionsForSave(), viewOptions: inquiryTab.dataView.getOptionsForSave(),
name: newName || inquiryTab.name name: newName || inquiryTab.name,
updatedAt: new Date().toJSON()
} }
// Get inquiries from local storage // Get inquiries from local storage
const myInquiries = state.inquiries const myInquiries = state.inquiries
let inquiryIndex
// Set createdAt // Set createdAt
if (newName) { if (newName) {
value.createdAt = new Date() value.createdAt = new Date().toJSON()
} else { } else {
var inquiryIndex = myInquiries.findIndex( inquiryIndex = myInquiries.findIndex(
oldInquiry => oldInquiry.id === inquiryTab.id oldInquiry => oldInquiry.id === inquiryTab.id
) )
value.createdAt = myInquiries[inquiryIndex].createdAt
value.createdAt =
inquiryIndex !== -1
? myInquiries[inquiryIndex].createdAt
: new Date().toJSON()
} }
// Insert in inquiries list // Insert in inquiries list
if (newName) { if (newName || inquiryIndex === -1) {
myInquiries.push(value) myInquiries.push(value)
} else { } else {
myInquiries.splice(inquiryIndex, 1, value) myInquiries.splice(inquiryIndex, 1, value)

View File

@@ -7,7 +7,8 @@ export default {
}, },
updateTab(state, { tab, newValues }) { updateTab(state, { tab, newValues }) {
const { name, id, query, viewType, viewOptions, isSaved } = newValues const { name, id, query, viewType, viewOptions, isSaved, updatedAt } =
newValues
const oldId = tab.id const oldId = tab.id
if (id && state.currentTabId === oldId) { if (id && state.currentTabId === oldId) {
@@ -36,6 +37,9 @@ export default {
// Saved inquiry is not predefined // Saved inquiry is not predefined
delete tab.isPredefined delete tab.isPredefined
} }
if (updatedAt) {
tab.updatedAt = updatedAt
}
}, },
deleteTab(state, tab) { deleteTab(state, tab) {

View File

@@ -10,14 +10,22 @@
</div> </div>
<div id="nav-buttons"> <div id="nav-buttons">
<button <button
v-show="currentInquiry && $route.path === '/workspace'" v-show="currentInquiryTab && $route.path === '/workspace'"
id="save-btn" id="save-btn"
class="primary" class="primary"
:disabled="isSaved" :disabled="isSaved"
@click="checkInquiryBeforeSave" @click="onSave(false)"
> >
Save Save
</button> </button>
<button
v-show="currentInquiryTab && $route.path === '/workspace'"
id="save-as-btn"
class="primary"
@click="onSaveAs"
>
Save as
</button>
<button id="create-btn" class="primary" @click="createNewInquiry"> <button id="create-btn" class="primary" @click="createNewInquiry">
Create Create
</button> </button>
@@ -45,7 +53,34 @@
</div> </div>
<div class="dialog-buttons-container"> <div class="dialog-buttons-container">
<button class="secondary" @click="cancelSave">Cancel</button> <button class="secondary" @click="cancelSave">Cancel</button>
<button class="primary" @click="saveInquiry">Save</button> <button class="primary" @click="validateSaveFormAndSaveInquiry">
Save
</button>
</div>
</modal>
<!-- Inquiery saving conflict dialog -->
<modal
modalId="inquiry-conflict"
class="dialog"
contentStyle="width: 560px;"
>
<div class="dialog-header">
Inquiry saving conflict
<close-icon @click="cancelSave" />
</div>
<div class="dialog-body">
<div id="save-note">
<img src="~@/assets/images/info.svg" />
This inquiry has been modified in the mean time. This can happen if an
inquiry is saved in another window or browser tab. Do you want to
overwrite that changes or save the current state as a new inquiry?
</div>
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="cancelSave">Cancel</button>
<button class="primary" @click="onSave(true)">Overwrite</button>
<button class="primary" @click="onSaveAs">Save as new</button>
</div> </div>
</modal> </modal>
</nav> </nav>
@@ -73,25 +108,28 @@ export default {
} }
}, },
computed: { computed: {
currentInquiry() { inquiries() {
return this.$store.state.inquiries
},
currentInquiryTab() {
return this.$store.state.currentTab return this.$store.state.currentTab
}, },
isSaved() { isSaved() {
return this.currentInquiry && this.currentInquiry.isSaved return this.currentInquiryTab && this.currentInquiryTab.isSaved
}, },
isPredefined() { isPredefined() {
return this.currentInquiry && this.currentInquiry.isPredefined return this.currentInquiryTab && this.currentInquiryTab.isPredefined
}, },
runDisabled() { runDisabled() {
return ( return (
this.currentInquiry && this.currentInquiryTab &&
(!this.$store.state.db || !this.currentInquiry.query) (!this.$store.state.db || !this.currentInquiryTab.query)
) )
} }
}, },
created() { created() {
eventBus.$on('createNewInquiry', this.createNewInquiry) eventBus.$on('createNewInquiry', this.createNewInquiry)
eventBus.$on('saveInquiry', this.checkInquiryBeforeSave) eventBus.$on('saveInquiry', this.onSave)
document.addEventListener('keydown', this._keyListener) document.addEventListener('keydown', this._keyListener)
}, },
beforeUnmount() { beforeUnmount() {
@@ -109,44 +147,73 @@ export default {
events.send('inquiry.create', null, { auto: false }) events.send('inquiry.create', null, { auto: false })
}, },
cancelSave() { cancelSave() {
this.$modal.hide('save')
eventBus.$off('inquirySaved')
},
checkInquiryBeforeSave() {
this.errorMsg = null this.errorMsg = null
this.name = '' this.name = ''
this.$modal.hide('save')
if (storedInquiries.isTabNeedName(this.currentInquiry)) { this.$modal.hide('inquiry-conflict')
this.$modal.show('save') eventBus.$off('inquirySaved')
} else {
this.saveInquiry()
}
}, },
async saveInquiry() { onSave(skipConcurrentEditingCheck = false) {
const isNeedName = storedInquiries.isTabNeedName(this.currentInquiry) if (storedInquiries.isTabNeedName(this.currentInquiryTab)) {
if (isNeedName && !this.name) { this.openSaveModal()
return
}
if (!skipConcurrentEditingCheck) {
const inquiryInStore = this.inquiries.find(
inquiry => inquiry.id === this.currentInquiryTab.id
)
if (
inquiryInStore &&
inquiryInStore.updatedAt !== this.currentInquiryTab.updatedAt
) {
this.$modal.show('inquiry-conflict')
return
}
}
this.saveInquiry()
},
onSaveAs() {
this.openSaveModal()
},
openSaveModal() {
this.errorMsg = null
this.name = ''
this.$modal.show('save')
},
validateSaveFormAndSaveInquiry() {
if (!this.name) {
this.errorMsg = "Inquiry name can't be empty" this.errorMsg = "Inquiry name can't be empty"
return return
} }
const dataSet = this.currentInquiry.result this.saveInquiry()
const tabView = this.currentInquiry.view },
async saveInquiry() {
const dataSet = this.currentInquiryTab.result
const tabView = this.currentInquiryTab.view
const eventName =
this.currentInquiryTab.name && this.name
? 'inquiry.saveAs'
: 'inquiry.save'
// Save inquiry // Save inquiry
const value = await this.$store.dispatch('saveInquiry', { const value = await this.$store.dispatch('saveInquiry', {
inquiryTab: this.currentInquiry, inquiryTab: this.currentInquiryTab,
newName: this.name newName: this.name
}) })
// Update tab in store // Update tab in store
this.$store.commit('updateTab', { this.$store.commit('updateTab', {
tab: this.currentInquiry, tab: this.currentInquiryTab,
newValues: { newValues: {
name: value.name, name: value.name,
id: value.id, id: value.id,
query: value.query, query: value.query,
viewType: value.viewType, viewType: value.viewType,
viewOptions: value.viewOptions, viewOptions: value.viewOptions,
isSaved: true isSaved: true,
updatedAt: value.updatedAt
} }
}) })
@@ -156,16 +223,19 @@ export default {
// it will be without sql result and has default view - table. // it will be without sql result and has default view - table.
// That's why we need to restore data and view // That's why we need to restore data and view
this.$nextTick(() => { this.$nextTick(() => {
this.currentInquiry.result = dataSet this.currentInquiryTab.result = dataSet
this.currentInquiry.view = tabView this.currentInquiryTab.view = tabView
}) })
// Hide dialog // Hide dialogs
this.$modal.hide('save') this.$modal.hide('save')
this.$modal.hide('inquiry-conflict')
this.errorMsg = null
this.name = ''
// Signal about saving // Signal about saving
eventBus.$emit('inquirySaved') eventBus.$emit('inquirySaved')
events.send('inquiry.save') events.send(eventName)
}, },
_keyListener(e) { _keyListener(e) {
if (this.$route.path === '/workspace') { if (this.$route.path === '/workspace') {
@@ -173,19 +243,25 @@ export default {
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) { if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
e.preventDefault() e.preventDefault()
if (!this.runDisabled) { if (!this.runDisabled) {
this.currentInquiry.execute() this.currentInquiryTab.execute()
} }
return return
} }
// Save inquiry Ctrl+S // Save inquiry Ctrl+S
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { if (e.key === 's' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault() e.preventDefault()
if (!this.isSaved) { if (!this.isSaved) {
this.checkInquiryBeforeSave() this.onSave()
} }
return return
} }
// Save inquiry as Ctrl+Shift+S
if (e.key === 'S' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
e.preventDefault()
this.onSaveAs()
return
}
} }
// New (blank) inquiry Ctrl+B // New (blank) inquiry Ctrl+B
if (e.key === 'b' && (e.ctrlKey || e.metaKey)) { if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {

View File

@@ -161,11 +161,8 @@ export default {
'text/html' 'text/html'
) )
}, },
async prepareCopy(type = 'png') { prepareCopy(type = 'png') {
return await chartHelper.getImageDataUrl( return chartHelper.getImageDataUrl(this.$refs.plotlyEditor.$el, type)
this.$refs.plotlyEditor.$el,
type
)
} }
} }
} }

View File

@@ -72,10 +72,10 @@
</side-tool-bar> </side-tool-bar>
<loading-dialog <loading-dialog
v-model="showLoadingDialog"
loadingMsg="Rendering the visualisation..." loadingMsg="Rendering the visualisation..."
successMsg="Image is ready" successMsg="Image is ready"
actionBtnName="Copy" actionBtnName="Copy"
name="prepareCopy"
title="Copy to clipboard" title="Copy to clipboard"
:loading="preparingCopy" :loading="preparingCopy"
@action="copyToClipboard" @action="copyToClipboard"
@@ -85,8 +85,8 @@
</template> </template>
<script> <script>
import Chart from './Chart' import Chart from './Chart/index.vue'
import Pivot from './Pivot' import Pivot from './Pivot/index.vue'
import SideToolBar from '../SideToolBar' import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton' import IconButton from '@/components/IconButton'
import ChartIcon from '@/components/svg/chart' import ChartIcon from '@/components/svg/chart'
@@ -96,7 +96,7 @@ import ExportToSvgIcon from '@/components/svg/exportToSvg'
import PngIcon from '@/components/svg/png' import PngIcon from '@/components/svg/png'
import ClipboardIcon from '@/components/svg/clipboard' import ClipboardIcon from '@/components/svg/clipboard'
import cIo from '@/lib/utils/clipboardIo' import cIo from '@/lib/utils/clipboardIo'
import loadingDialog from '@/components/LoadingDialog' import loadingDialog from '@/components/LoadingDialog.vue'
import time from '@/lib/utils/time' import time from '@/lib/utils/time'
import events from '@/lib/utils/events' import events from '@/lib/utils/events'
@@ -129,7 +129,8 @@ export default {
loadingImage: false, loadingImage: false,
copyingImage: false, copyingImage: false,
preparingCopy: false, preparingCopy: false,
dataToCopy: null dataToCopy: null,
showLoadingDialog: false
} }
}, },
computed: { computed: {
@@ -170,14 +171,13 @@ export default {
async prepareCopy() { async prepareCopy() {
if ('ClipboardItem' in window) { if ('ClipboardItem' in window) {
this.preparingCopy = true this.preparingCopy = true
this.$modal.show('prepareCopy') this.showLoadingDialog = true
const t0 = performance.now() const t0 = performance.now()
await time.sleep(0) await time.sleep(0)
this.dataToCopy = await this.$refs.viewComponent.prepareCopy() this.dataToCopy = await this.$refs.viewComponent.prepareCopy()
const t1 = performance.now() const t1 = performance.now()
if (t1 - t0 < 950) { if (t1 - t0 < 950) {
this.$modal.hide('prepareCopy')
this.copyToClipboard() this.copyToClipboard()
} else { } else {
this.preparingCopy = false this.preparingCopy = false
@@ -190,14 +190,13 @@ export default {
) )
} }
}, },
async copyToClipboard() { copyToClipboard() {
cIo.copyImage(this.dataToCopy) cIo.copyImage(this.dataToCopy)
this.$modal.hide('prepareCopy') this.showLoadingDialog = false
this.exportSignal('clipboard') this.exportSignal('clipboard')
}, },
cancelCopy() { cancelCopy() {
this.dataToCopy = null this.dataToCopy = null
this.$modal.hide('prepareCopy')
}, },
saveAsSvg() { saveAsSvg() {

View File

@@ -80,10 +80,10 @@
</side-tool-bar> </side-tool-bar>
<loading-dialog <loading-dialog
v-model="showLoadingDialog"
loadingMsg="Building CSV..." loadingMsg="Building CSV..."
successMsg="CSV is ready" successMsg="CSV is ready"
actionBtnName="Copy" actionBtnName="Copy"
name="prepareCSVCopy"
title="Copy to clipboard" title="Copy to clipboard"
:loading="preparingCopy" :loading="preparingCopy"
@action="copyToClipboard" @action="copyToClipboard"
@@ -190,7 +190,8 @@ export default {
viewRecord: false, viewRecord: false,
defaultPage: 1, defaultPage: 1,
defaultSelectedCell: null, defaultSelectedCell: null,
enableTeleport: this.$store.state.isWorkspaceVisible enableTeleport: this.$store.state.isWorkspaceVisible,
showLoadingDialog: false
} }
}, },
computed: { computed: {
@@ -264,14 +265,13 @@ export default {
if ('ClipboardItem' in window) { if ('ClipboardItem' in window) {
this.preparingCopy = true this.preparingCopy = true
this.$modal.show('prepareCSVCopy') this.showLoadingDialog = true
const t0 = performance.now() const t0 = performance.now()
await time.sleep(0) await time.sleep(0)
this.dataToCopy = csv.serialize(this.result) this.dataToCopy = csv.serialize(this.result)
const t1 = performance.now() const t1 = performance.now()
if (t1 - t0 < 950) { if (t1 - t0 < 950) {
this.$modal.hide('prepareCSVCopy')
this.copyToClipboard() this.copyToClipboard()
} else { } else {
this.preparingCopy = false this.preparingCopy = false
@@ -287,12 +287,11 @@ export default {
copyToClipboard() { copyToClipboard() {
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully') cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
this.$modal.hide('prepareCSVCopy') this.showLoadingDialog = false
}, },
cancelCopy() { cancelCopy() {
this.dataToCopy = null this.dataToCopy = null
this.$modal.hide('prepareCSVCopy')
}, },
toggleViewValuePanel() { toggleViewValuePanel() {

View File

@@ -2,7 +2,7 @@ import { expect } from 'chai'
import sinon from 'sinon' import sinon from 'sinon'
import { shallowMount } from '@vue/test-utils' import { shallowMount } from '@vue/test-utils'
import { createStore } from 'vuex' import { createStore } from 'vuex'
import App from '@/App' import App from '@/App.vue'
import storedInquiries from '@/lib/storedInquiries' import storedInquiries from '@/lib/storedInquiries'
import mutations from '@/store/mutations' import mutations from '@/store/mutations'
import { nextTick } from 'vue' import { nextTick } from 'vue'
@@ -59,4 +59,37 @@ describe('App.vue', () => {
{ id: 3, name: 'bar' } { id: 3, name: 'bar' }
]) ])
}) })
it('Updates store when inquirires change in local storage', async () => {
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{ id: 1, name: 'foo' },
{ id: 2, name: 'baz' },
{ id: 3, name: 'bar' }
])
const state = {
predefinedInquiries: [],
inquiries: []
}
const store = createStore({ state, mutations })
shallowMount(App, {
global: { stubs: ['router-view'], plugins: [store] }
})
expect(state.inquiries).to.eql([
{ id: 1, name: 'foo' },
{ id: 2, name: 'baz' },
{ id: 3, name: 'bar' }
])
storedInquiries.getStoredInquiries.returns([
{ id: 1, name: 'foo' },
{ id: 3, name: 'bar' }
])
window.dispatchEvent(new StorageEvent('storage', { key: 'myInquiries' }))
expect(state.inquiries).to.eql([
{ id: 1, name: 'foo' },
{ id: 3, name: 'bar' }
])
})
}) })

View File

@@ -71,7 +71,7 @@ describe('storedInquiries.js', () => {
query: 'SELECT * from foo', query: 'SELECT * from foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [], viewOptions: [],
createdAt: new Date(2021, 0, 1), createdAt: new Date(2021, 0, 1).toJSON(),
isPredefined: true isPredefined: true
} }
@@ -83,7 +83,8 @@ describe('storedInquiries.js', () => {
expect(copy).to.have.property('query').which.equal(base.query) expect(copy).to.have.property('query').which.equal(base.query)
expect(copy).to.have.property('viewType').which.equal(base.viewType) expect(copy).to.have.property('viewType').which.equal(base.viewType)
expect(copy).to.have.property('viewOptions').which.eql(base.viewOptions) expect(copy).to.have.property('viewOptions').which.eql(base.viewOptions)
expect(copy).to.have.property('createdAt').which.within(now, nowPlusMinute) expect(copy).to.have.property('createdAt')
expect(new Date(copy.createdAt)).within(now, nowPlusMinute)
expect(copy).to.not.have.property('isPredefined') expect(copy).to.not.have.property('isPredefined')
}) })

View File

@@ -15,6 +15,7 @@ describe('tab.js', () => {
query: undefined, query: undefined,
viewOptions: undefined, viewOptions: undefined,
isPredefined: undefined, isPredefined: undefined,
updatedAt: undefined,
viewType: 'chart', viewType: 'chart',
result: null, result: null,
isGettingResults: false, isGettingResults: false,
@@ -42,7 +43,8 @@ describe('tab.js', () => {
viewType: 'pivot', viewType: 'pivot',
viewOptions: 'this is view options object', viewOptions: 'this is view options object',
name: 'Foo inquiry', name: 'Foo inquiry',
createdAt: '2022-12-05T18:30:30' createdAt: '2022-12-05T18:30:30',
updatedAt: '2022-12-06T18:30:30'
} }
const newTab = new Tab(state, inquiry) const newTab = new Tab(state, inquiry)
@@ -53,6 +55,7 @@ describe('tab.js', () => {
query: 'SELECT * from foo', query: 'SELECT * from foo',
viewOptions: 'this is view options object', viewOptions: 'this is view options object',
isPredefined: undefined, isPredefined: undefined,
updatedAt: '2022-12-06T18:30:30',
viewType: 'pivot', viewType: 'pivot',
result: null, result: null,
isGettingResults: false, isGettingResults: false,

View File

@@ -19,7 +19,8 @@ describe('actions', () => {
tempName: 'Untitled', tempName: 'Untitled',
viewType: 'chart', viewType: 'chart',
viewOptions: undefined, viewOptions: undefined,
isSaved: false isSaved: false,
updatedAt: undefined
}) })
expect(state.untitledLastIndex).to.equal(1) expect(state.untitledLastIndex).to.equal(1)
@@ -30,7 +31,8 @@ describe('actions', () => {
tempName: 'Untitled 1', tempName: 'Untitled 1',
viewType: 'chart', viewType: 'chart',
viewOptions: undefined, viewOptions: undefined,
isSaved: false isSaved: false,
updatedAt: undefined
}) })
expect(state.untitledLastIndex).to.equal(2) expect(state.untitledLastIndex).to.equal(2)
}) })
@@ -40,16 +42,16 @@ describe('actions', () => {
tabs: [], tabs: [],
untitledLastIndex: 0 untitledLastIndex: 0
} }
const tab = { const inquiry = {
id: 1, id: 1,
name: 'test', name: 'test',
query: 'SELECT * from foo', query: 'SELECT * from foo',
viewType: 'chart', viewType: 'chart',
viewOptions: 'an object with view options', viewOptions: 'an object with view options',
isSaved: true updatedAt: '2025-05-16T20:15:00Z'
} }
await addTab({ state }, tab) await addTab({ state }, inquiry)
expect(state.tabs[0]).to.include(tab) expect(state.tabs[0]).to.include(inquiry)
expect(state.untitledLastIndex).to.equal(0) expect(state.untitledLastIndex).to.equal(0)
}) })
@@ -166,21 +168,26 @@ describe('actions', () => {
newName: 'foo' newName: 'foo'
} }
) )
expect(value.id).to.equal(tab.id) expect(value.id).not.to.equal(tab.id)
expect(value.name).to.equal('foo') expect(value.name).to.equal('foo')
expect(value.query).to.equal(tab.query) expect(value.query).to.equal(tab.query)
expect(value.viewOptions).to.eql(['chart']) expect(value.viewOptions).to.eql(['chart'])
expect(value).to.have.property('createdAt').which.within(now, nowPlusMinute) expect(value).to.have.property('createdAt')
expect(new Date(value.createdAt)).within(now, nowPlusMinute)
expect(new Date(value.updatedAt)).within(now, nowPlusMinute)
expect(state.inquiries).to.eql([value]) expect(state.inquiries).to.eql([value])
}) })
it('save updates existing inquiry in the storage', async () => { it('saveInquiry updates existing inquiry in the storage', async () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = { const tab = {
id: 1, id: 1,
query: 'select * from foo', query: 'select * from foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [], viewOptions: [],
name: null, name: 'foo',
updatedAt: '2025-05-16T20:15:00Z',
dataView: { dataView: {
getOptionsForSave() { getOptionsForSave() {
return ['chart'] return ['chart']
@@ -189,34 +196,34 @@ describe('actions', () => {
} }
const state = { const state = {
inquiries: [], inquiries: [
{
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo',
createdAt: '2025-05-15T16:30:00Z',
updatedAt: '2025-05-16T20:15:00Z'
}
],
tabs: [tab] tabs: [tab]
} }
const first = await saveInquiry( tab.query = 'select * from bar'
{ state }, await saveInquiry({ state }, { inquiryTab: tab, newName: '' })
{
inquiryTab: tab,
newName: 'foo'
}
)
tab.name = 'foo'
tab.query = 'select * from foo'
await saveInquiry({ state }, { inquiryTab: tab })
const inquiries = state.inquiries const inquiries = state.inquiries
const second = inquiries[0] const updatedTab = inquiries[0]
expect(inquiries).has.lengthOf(1) expect(inquiries).has.lengthOf(1)
expect(second.id).to.equal(first.id) expect(updatedTab.id).to.equal(updatedTab.id)
expect(second.name).to.equal(first.name) expect(updatedTab.name).to.equal(updatedTab.name)
expect(second.query).to.equal(tab.query) expect(updatedTab.query).to.equal(tab.query)
expect(second.viewOptions).to.eql(['chart']) expect(updatedTab.viewOptions).to.eql(['chart'])
expect(new Date(second.createdAt).getTime()).to.equal( expect(updatedTab.createdAt).to.equal('2025-05-15T16:30:00Z')
first.createdAt.getTime() expect(new Date(updatedTab.updatedAt)).to.be.within(now, nowPlusMinute)
)
}) })
it("save adds a new inquiry with new id if it's based on predefined inquiry", async () => { it("saveInquiry adds a new inquiry with new id if it's based on predefined inquiry", async () => {
const now = new Date() const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000) const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = { const tab = {
@@ -252,6 +259,95 @@ describe('actions', () => {
expect(inquiries[0].name).to.equal('foo') expect(inquiries[0].name).to.equal('foo')
expect(inquiries[0].query).to.equal(tab.query) expect(inquiries[0].query).to.equal(tab.query)
expect(inquiries[0].viewOptions).to.eql(['chart']) expect(inquiries[0].viewOptions).to.eql(['chart'])
expect(new Date(inquiries[0].updatedAt)).to.be.within(now, nowPlusMinute)
expect(new Date(inquiries[0].createdAt)).to.be.within(now, nowPlusMinute) expect(new Date(inquiries[0].createdAt)).to.be.within(now, nowPlusMinute)
}) })
it('saveInquiry adds new inquiry if newName is provided', async () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo',
updatedAt: '2025-05-16T20:15:00Z',
dataView: {
getOptionsForSave() {
return ['chart']
}
}
}
const inquiry = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo',
createdAt: '2025-05-15T16:30:00Z',
updatedAt: '2025-05-16T20:15:00Z'
}
const state = {
inquiries: [inquiry],
tabs: [tab]
}
const value = await saveInquiry(
{ state },
{
inquiryTab: tab,
newName: 'foo_new'
}
)
expect(value.id).not.to.equal(tab.id)
expect(value.name).to.equal('foo_new')
expect(value.query).to.equal(tab.query)
expect(value.viewOptions).to.eql(['chart'])
expect(value).to.have.property('createdAt')
expect(new Date(value.createdAt)).within(now, nowPlusMinute)
expect(new Date(value.updatedAt)).within(now, nowPlusMinute)
expect(state.inquiries).to.eql([inquiry, value])
})
it('saveInquiry adds new inquiry if the inquiry is not in the storeage anymore', async () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo',
updatedAt: '2025-05-16T20:15:00Z',
dataView: {
getOptionsForSave() {
return ['chart']
}
}
}
const state = {
inquiries: [],
tabs: [tab]
}
const value = await saveInquiry(
{ state },
{
inquiryTab: tab,
newName: ''
}
)
expect(value.id).to.equal(tab.id)
expect(value.name).to.equal('foo')
expect(value.query).to.equal(tab.query)
expect(value.viewOptions).to.eql(['chart'])
expect(value).to.have.property('createdAt')
expect(new Date(value.createdAt)).within(now, nowPlusMinute)
expect(new Date(value.updatedAt)).within(now, nowPlusMinute)
expect(state.inquiries).to.eql([value])
})
}) })

View File

@@ -34,7 +34,8 @@ describe('mutations', () => {
viewType: 'chart', viewType: 'chart',
viewOptions: { here_are: 'chart settings' }, viewOptions: { here_are: 'chart settings' },
isSaved: false, isSaved: false,
isPredefined: false isPredefined: false,
updatedAt: '2025-05-15T15:30:00Z'
} }
const newValues = { const newValues = {
@@ -43,6 +44,7 @@ describe('mutations', () => {
query: 'SELECT * from bar', query: 'SELECT * from bar',
viewType: 'pivot', viewType: 'pivot',
viewOptions: { here_are: 'pivot settings' }, viewOptions: { here_are: 'pivot settings' },
updatedAt: '2025-05-15T16:30:00Z',
isSaved: true isSaved: true
} }
@@ -58,6 +60,7 @@ describe('mutations', () => {
query: 'SELECT * from bar', query: 'SELECT * from bar',
viewType: 'pivot', viewType: 'pivot',
viewOptions: { here_are: 'pivot settings' }, viewOptions: { here_are: 'pivot settings' },
updatedAt: '2025-05-15T16:30:00Z',
isSaved: true isSaved: true
}) })
}) })

View File

@@ -6,6 +6,8 @@ import MainMenu from '@/views/MainView/MainMenu'
import storedInquiries from '@/lib/storedInquiries' import storedInquiries from '@/lib/storedInquiries'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import eventBus from '@/lib/eventBus' import eventBus from '@/lib/eventBus'
import actions from '@/store/actions'
import mutations from '@/store/mutations'
let wrapper = null let wrapper = null
@@ -26,7 +28,7 @@ describe('MainMenu.vue', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('Create and Save are visible only on /workspace page', async () => { it('Create, Save and Save as are visible only on /workspace page', async () => {
const state = { const state = {
currentTab: { query: '', execute: sinon.stub() }, currentTab: { query: '', execute: sinon.stub() },
tabs: [{}], tabs: [{}],
@@ -45,6 +47,8 @@ describe('MainMenu.vue', () => {
}) })
expect(wrapper.find('#save-btn').exists()).to.equal(true) expect(wrapper.find('#save-btn').exists()).to.equal(true)
expect(wrapper.find('#save-btn').isVisible()).to.equal(true) expect(wrapper.find('#save-btn').isVisible()).to.equal(true)
expect(wrapper.find('#save-as-btn').exists()).to.equal(true)
expect(wrapper.find('#save-as-btn').isVisible()).to.equal(true)
expect(wrapper.find('#create-btn').exists()).to.equal(true) expect(wrapper.find('#create-btn').exists()).to.equal(true)
expect(wrapper.find('#create-btn').isVisible()).to.equal(true) expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
wrapper.unmount() wrapper.unmount()
@@ -65,7 +69,7 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('#create-btn').isVisible()).to.equal(true) expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
}) })
it('Save is not visible if there is no tabs', () => { it('Save and Save as are not visible if there is no tabs', () => {
const state = { const state = {
currentTab: null, currentTab: null,
tabs: [], tabs: [],
@@ -83,6 +87,8 @@ describe('MainMenu.vue', () => {
}) })
expect(wrapper.find('#save-btn').exists()).to.equal(true) expect(wrapper.find('#save-btn').exists()).to.equal(true)
expect(wrapper.find('#save-btn').isVisible()).to.equal(false) expect(wrapper.find('#save-btn').isVisible()).to.equal(false)
expect(wrapper.find('#save-as-btn').exists()).to.equal(true)
expect(wrapper.find('#save-as-btn').isVisible()).to.equal(false)
expect(wrapper.find('#create-btn').exists()).to.equal(true) expect(wrapper.find('#create-btn').exists()).to.equal(true)
expect(wrapper.find('#create-btn').isVisible()).to.equal(true) expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
}) })
@@ -111,10 +117,12 @@ describe('MainMenu.vue', () => {
}) })
const vm = wrapper.vm const vm = wrapper.vm
expect(wrapper.find('#save-btn').element.disabled).to.equal(false) expect(wrapper.find('#save-btn').element.disabled).to.equal(false)
expect(wrapper.find('#save-as-btn').element.disabled).to.equal(false)
store.state.tabs[0].isSaved = true store.state.tabs[0].isSaved = true
await vm.$nextTick() await vm.$nextTick()
expect(wrapper.find('#save-btn').element.disabled).to.equal(true) expect(wrapper.find('#save-btn').element.disabled).to.equal(true)
expect(wrapper.find('#save-as-btn').element.disabled).to.equal(false)
}) })
it('Creates a tab', async () => { it('Creates a tab', async () => {
@@ -332,7 +340,7 @@ describe('MainMenu.vue', () => {
expect(wrapper.vm.createNewInquiry.callCount).to.equal(4) expect(wrapper.vm.createNewInquiry.callCount).to.equal(4)
}) })
it('Ctrl S calls checkInquiryBeforeSave if the tab is unsaved and route path is /workspace', async () => { it('Ctrl S calls onSave if the tab is unsaved and route path is /workspace', async () => {
const tab = { const tab = {
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
execute: sinon.stub(), execute: sinon.stub(),
@@ -353,36 +361,34 @@ describe('MainMenu.vue', () => {
plugins: [store] plugins: [store]
} }
}) })
sinon.stub(wrapper.vm, 'checkInquiryBeforeSave') sinon.stub(wrapper.vm, 'onSave')
const ctrlS = new KeyboardEvent('keydown', { key: 's', ctrlKey: true }) const ctrlS = new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
const metaS = new KeyboardEvent('keydown', { key: 's', metaKey: true }) const metaS = new KeyboardEvent('keydown', { key: 's', metaKey: true })
// tab is unsaved and route is /workspace // tab is unsaved and route is /workspace
document.dispatchEvent(ctrlS) document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledOnce).to.equal(true) expect(wrapper.vm.onSave.calledOnce).to.equal(true)
document.dispatchEvent(metaS) document.dispatchEvent(metaS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true) expect(wrapper.vm.onSave.calledTwice).to.equal(true)
// tab is saved and route is /workspace // tab is saved and route is /workspace
store.state.tabs[0].isSaved = true store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS) document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true) expect(wrapper.vm.onSave.calledTwice).to.equal(true)
document.dispatchEvent(metaS) document.dispatchEvent(metaS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true) expect(wrapper.vm.onSave.calledTwice).to.equal(true)
// tab is unsaved and route is not /workspace // tab is unsaved and route is not /workspace
wrapper.vm.$route.path = '/inquiries' wrapper.vm.$route.path = '/inquiries'
store.state.tabs[0].isSaved = false store.state.tabs[0].isSaved = false
document.dispatchEvent(ctrlS) document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true) expect(wrapper.vm.onSave.calledTwice).to.equal(true)
document.dispatchEvent(metaS) document.dispatchEvent(metaS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true) expect(wrapper.vm.onSave.calledTwice).to.equal(true)
}) })
it('Saves the inquiry when no need the new name', async () => { it('Ctrl Shift S calls onSaveAs if route path is /workspace', async () => {
const tab = { const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
execute: sinon.stub(), execute: sinon.stub(),
isSaved: false isSaved: false
@@ -392,6 +398,81 @@ describe('MainMenu.vue', () => {
tabs: [tab], tabs: [tab],
db: {} db: {}
} }
const store = createStore({ state })
const $route = { path: '/workspace' }
wrapper = shallowMount(MainMenu, {
global: {
mocks: { $route },
stubs: ['router-link'],
plugins: [store]
}
})
sinon.stub(wrapper.vm, 'onSaveAs')
const ctrlS = new KeyboardEvent('keydown', {
key: 'S',
ctrlKey: true,
shiftKey: true
})
const metaS = new KeyboardEvent('keydown', {
key: 'S',
metaKey: true,
shiftKey: true
})
// tab is unsaved and route is /workspace
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.calledOnce).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.calledTwice).to.equal(true)
// tab is saved and route is /workspace
store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.calledThrice).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
// tab is unsaved and route is not /workspace
wrapper.vm.$route.path = '/inquiries'
store.state.tabs[0].isSaved = false
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
// tab is saved and route is not /workspace
wrapper.vm.$route.path = '/inquiries'
store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
})
it('Saves the inquiry when no need the new name and no update conflict', async () => {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: tab,
inquiries: [
{
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
createdAt: '2025-05-14T15:30:00Z'
}
],
tabs: [tab],
db: {}
}
const mutations = { const mutations = {
updateTab: sinon.stub() updateTab: sinon.stub()
} }
@@ -401,7 +482,8 @@ describe('MainMenu.vue', () => {
id: 1, id: 1,
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [] viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
}) })
} }
const store = createStore({ state, mutations, actions }) const store = createStore({ state, mutations, actions })
@@ -446,7 +528,8 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [], viewOptions: [],
isSaved: true isSaved: true,
updatedAt: '2025-05-16T15:30:00Z'
} }
}) })
) )
@@ -456,6 +539,398 @@ describe('MainMenu.vue', () => {
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true) expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
}) })
it('Inquiry conflict: overwrite', async () => {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: tab,
inquiries: [
{
id: 1,
name: 'foo',
query: 'SELECT * FROM bar',
updatedAt: '2025-05-15T16:30:00Z',
createdAt: '2025-05-14T15:30:00Z'
}
],
tabs: [tab],
db: {}
}
const mutations = {
updateTab: sinon.stub()
}
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
updatedAt: '2025-05-16T17:30:00Z',
createdAt: '2025-05-14T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
wrapper = mount(MainMenu, {
attachTo: document.body,
global: {
mocks: { $route },
stubs: {
'router-link': true,
'app-diagnostic-info': true,
teleport: true,
transition: false
},
plugins: [store]
}
})
await wrapper.find('#save-btn').trigger('click')
// check that the conflict dialog is open
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Inquiry saving conflict'
)
// find Overwrite in the dialog and click
await wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Overwrite')
.trigger('click')
await nextTick()
// check that the dialog is closed
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// check that the inquiry was saved via saveInquiry (newName='')
expect(actions.saveInquiry.calledOnce).to.equal(true)
expect(actions.saveInquiry.args[0][1]).to.eql({
inquiryTab: state.currentTab,
newName: ''
})
// check that the tab was updated
expect(
mutations.updateTab.calledOnceWith(
state,
sinon.match({
tab,
newValues: {
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true,
updatedAt: '2025-05-16T17:30:00Z'
}
})
)
).to.equal(true)
// check that 'inquirySaved' event was triggered on eventBus
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
})
it('Inquiry conflict after saving new inquiry: overwrite', async () => {
const tab = {
id: 1,
name: null,
query: 'SELECT * FROM foo',
updatedAt: undefined,
execute: sinon.stub(),
dataView: { getOptionsForSave: sinon.stub() },
isSaved: false
}
const state = {
currentTab: tab,
inquiries: [],
tabs: [tab],
db: {}
}
const store = createStore({ state, mutations, actions })
const $route = { path: '/workspace' }
wrapper = mount(MainMenu, {
attachTo: document.body,
global: {
mocks: { $route },
stubs: {
'router-link': true,
'app-diagnostic-info': true,
teleport: true,
transition: false
},
plugins: [store]
}
})
await wrapper.find('#save-btn').trigger('click')
// check that Save dialog is open
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Save inquiry'
)
// enter the name
await wrapper.find('.dialog-body input').setValue('foo')
// find Save in the dialog and click
await wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Save')
.trigger('click')
await nextTick()
// check that the dialog is closed
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// check that now there is one inquiry saved
expect(state.inquiries.length).to.equal(1)
expect(state.inquiries[0].name).to.equal('foo')
expect(state.tabs[0].name).to.equal('foo')
// change the inquiry in store (like it's updated in another tab)
store.state.inquiries[0].query = 'SELECT * FROM foo_updated_in_another_tab'
store.state.inquiries[0].updatedAt = '2025-05-15T00:00:10Z'
store.state.currentTab.query = 'SELECT * FROM foo_new'
store.state.currentTab.isSaved = false
await nextTick()
await wrapper.find('#save-btn').trigger('click')
// check that the conflict dialog is open
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Inquiry saving conflict'
)
// find Overwrite in the dialog and click
await wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Overwrite')
.trigger('click')
await nextTick()
// check that the dialog is closed
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// check that it's still one inquiry saved
expect(state.inquiries.length).to.equal(1)
expect(state.inquiries[0].name).to.equal('foo')
expect(state.tabs[0].name).to.equal('foo')
expect(state.inquiries[0].query).to.equal('SELECT * FROM foo_new')
expect(state.tabs[0].query).to.equal('SELECT * FROM foo_new')
})
it('Inquiry conflict: save as new', async () => {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: tab,
inquiries: [
{
id: 1,
name: 'foo',
query: 'SELECT * FROM bar',
updatedAt: '2025-05-15T16:30:00Z',
createdAt: '2025-05-14T15:30:00Z'
}
],
tabs: [tab],
db: {}
}
const mutations = {
updateTab: sinon.stub()
}
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo_new',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
updatedAt: '2025-05-16T17:30:00Z',
createdAt: '2025-05-16T17:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
wrapper = mount(MainMenu, {
attachTo: document.body,
global: {
mocks: { $route },
stubs: {
'router-link': true,
'app-diagnostic-info': true,
teleport: true,
transition: false
},
plugins: [store]
}
})
await wrapper.find('#save-btn').trigger('click')
// check that the conflict dialog is open
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Inquiry saving conflict'
)
// find "Save as new" in the dialog and click
await wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Save as new')
.trigger('click')
await nextTick()
await clock.tick(100)
// enter the new name
await wrapper.find('.dialog-body input').setValue('foo_new')
// find Save in the dialog and click
await wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Save')
.trigger('click')
await nextTick()
// check that the dialog is closed
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// check that the inquiry was saved via saveInquiry (newName='foo_new')
expect(actions.saveInquiry.calledOnce).to.equal(true)
expect(actions.saveInquiry.args[0][1]).to.eql({
inquiryTab: state.currentTab,
newName: 'foo_new'
})
// check that the tab was updated
expect(
mutations.updateTab.calledOnceWith(
state,
sinon.match({
tab,
newValues: {
name: 'foo_new',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true,
updatedAt: '2025-05-16T17:30:00Z'
}
})
)
).to.equal(true)
// check that 'inquirySaved' event was triggered on eventBus
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
})
it('Inquiry conflict: cancel', async () => {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: tab,
inquiries: [
{
id: 1,
name: 'foo',
query: 'SELECT * FROM bar',
updatedAt: '2025-05-15T16:30:00Z',
createdAt: '2025-05-14T15:30:00Z'
}
],
tabs: [tab],
db: {}
}
const mutations = {
updateTab: sinon.stub()
}
const actions = {
saveInquiry: sinon.stub()
}
const store = createStore({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
wrapper = mount(MainMenu, {
attachTo: document.body,
global: {
mocks: { $route },
stubs: {
'router-link': true,
'app-diagnostic-info': true,
teleport: true,
transition: false
},
plugins: [store]
}
})
await wrapper.find('#save-btn').trigger('click')
// check that the conflict dialog is open
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Inquiry saving conflict'
)
// find Cancel in the dialog and click
await wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Cancel')
.trigger('click')
// check that the dialog is closed
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// check that the inquiry was not saved via storedInquiries.save
expect(actions.saveInquiry.called).to.equal(false)
// check that the tab was not updated
expect(mutations.updateTab.called).to.equal(false)
// check that 'inquirySaved' event is not listened on eventBus
expect(eventBus.$off.calledOnceWith('inquirySaved')).to.equal(true)
})
it('Shows en error when the new name is needed but not specifyied', async () => { it('Shows en error when the new name is needed but not specifyied', async () => {
const tab = { const tab = {
id: 1, id: 1,
@@ -463,7 +938,8 @@ describe('MainMenu.vue', () => {
tempName: 'Untitled', tempName: 'Untitled',
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
execute: sinon.stub(), execute: sinon.stub(),
isSaved: false isSaved: false,
updatedAt: '2025-05-15T15:30:00Z'
} }
const state = { const state = {
currentTab: tab, currentTab: tab,
@@ -479,7 +955,8 @@ describe('MainMenu.vue', () => {
id: 1, id: 1,
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [] viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
}) })
} }
const store = createStore({ state, mutations, actions }) const store = createStore({ state, mutations, actions })
@@ -522,14 +999,15 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true) expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
}) })
it('Saves the inquiry with a new name', async () => { it('Saves the new inquiry with a new name', async () => {
const tab = { const tab = {
id: 1, id: 1,
name: null, name: null,
tempName: 'Untitled', tempName: 'Untitled',
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
execute: sinon.stub(), execute: sinon.stub(),
isSaved: false isSaved: false,
updatedAt: undefined
} }
const state = { const state = {
currentTab: tab, currentTab: tab,
@@ -542,10 +1020,11 @@ describe('MainMenu.vue', () => {
const actions = { const actions = {
saveInquiry: sinon.stub().returns({ saveInquiry: sinon.stub().returns({
name: 'foo', name: 'foo',
id: 1, id: 2,
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [] viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
}) })
} }
const store = createStore({ state, mutations, actions }) const store = createStore({ state, mutations, actions })
@@ -604,11 +1083,12 @@ describe('MainMenu.vue', () => {
tab, tab,
newValues: { newValues: {
name: 'foo', name: 'foo',
id: 1, id: 2,
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [], viewOptions: [],
isSaved: true isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
} }
}) })
) )
@@ -650,7 +1130,8 @@ describe('MainMenu.vue', () => {
id: 2, id: 2,
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [] viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
}) })
} }
const store = createStore({ state, mutations, actions }) const store = createStore({ state, mutations, actions })
@@ -716,7 +1197,8 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
viewType: 'chart', viewType: 'chart',
viewOptions: [], viewOptions: [],
isSaved: true isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
} }
}) })
) )
@@ -761,7 +1243,7 @@ describe('MainMenu.vue', () => {
name: 'bar', name: 'bar',
id: 2, id: 2,
query: 'SELECT * FROM foo', query: 'SELECT * FROM foo',
chart: [] viewType: 'chart'
}) })
} }
const store = createStore({ state, mutations, actions }) const store = createStore({ state, mutations, actions })
@@ -809,4 +1291,112 @@ describe('MainMenu.vue', () => {
// check that 'inquirySaved' event is not listened on eventBus // check that 'inquirySaved' event is not listened on eventBus
expect(eventBus.$off.calledOnceWith('inquirySaved')).to.equal(true) expect(eventBus.$off.calledOnceWith('inquirySaved')).to.equal(true)
}) })
it('Save the inquiry as new', async () => {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
execute: sinon.stub(),
isSaved: true
}
const state = {
currentTab: tab,
inquiries: [
{
id: 1,
name: 'foo',
query: 'SELECT * FROM bar',
updatedAt: '2025-05-15T16:30:00Z',
createdAt: '2025-05-14T15:30:00Z'
}
],
tabs: [tab],
db: {}
}
const mutations = {
updateTab: sinon.stub()
}
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo_new',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
updatedAt: '2025-05-16T17:30:00Z',
createdAt: '2025-05-16T17:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
wrapper = mount(MainMenu, {
attachTo: document.body,
global: {
mocks: { $route },
stubs: {
'router-link': true,
'app-diagnostic-info': true,
teleport: true,
transition: false
},
plugins: [store]
}
})
await wrapper.find('#save-as-btn').trigger('click')
// check that Save dialog is open
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Save inquiry'
)
// enter the new name
await wrapper.find('.dialog-body input').setValue('foo_new')
// find Save in the dialog and click
await wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Save')
.trigger('click')
await nextTick()
// check that the dialog is closed
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// check that the inquiry was saved via saveInquiry (newName='foo_new')
expect(actions.saveInquiry.calledOnce).to.equal(true)
expect(actions.saveInquiry.args[0][1]).to.eql({
inquiryTab: state.currentTab,
newName: 'foo_new'
})
// check that the tab was updated
expect(
mutations.updateTab.calledOnceWith(
state,
sinon.match({
tab,
newValues: {
name: 'foo_new',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true,
updatedAt: '2025-05-16T17:30:00Z'
}
})
)
).to.equal(true)
// check that 'inquirySaved' event was triggered on eventBus
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
})
}) })

View File

@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'
import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView' import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView'
import sinon from 'sinon' import sinon from 'sinon'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import cIo from '@/lib/utils/clipboardIo'
describe('DataView.vue', () => { describe('DataView.vue', () => {
const $store = { state: { isWorkspaceVisible: true } } const $store = { state: { isWorkspaceVisible: true } }
@@ -64,7 +65,7 @@ describe('DataView.vue', () => {
// Find chart and spy the method // Find chart and spy the method
const chart = wrapper.findComponent({ name: 'Chart' }).vm const chart = wrapper.findComponent({ name: 'Chart' }).vm
sinon.spy(chart, 'saveAsSvg') sinon.stub(chart, 'saveAsSvg')
// Export to svg // Export to svg
const svgBtn = wrapper.findComponent({ ref: 'svgExportBtn' }) const svgBtn = wrapper.findComponent({ ref: 'svgExportBtn' })
@@ -77,7 +78,7 @@ describe('DataView.vue', () => {
// Find pivot and spy the method // Find pivot and spy the method
const pivot = wrapper.findComponent({ name: 'pivot' }).vm const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsSvg') sinon.stub(pivot, 'saveAsSvg')
// Switch to Custom Chart renderer // Switch to Custom Chart renderer
pivot.pivotOptions.rendererName = 'Custom chart' pivot.pivotOptions.rendererName = 'Custom chart'
@@ -146,6 +147,7 @@ describe('DataView.vue', () => {
it('copy to clipboard more than 1 sec', async () => { it('copy to clipboard more than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'write').resolves() sinon.stub(window.navigator.clipboard, 'write').resolves()
sinon.stub(cIo, 'copyImage')
const clock = sinon.useFakeTimers() const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, { const wrapper = mount(DataView, {
attachTo: document.body, attachTo: document.body,
@@ -165,7 +167,7 @@ describe('DataView.vue', () => {
await copyBtn.trigger('click') await copyBtn.trigger('click')
// The dialog is shown... // The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain( expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Copy to clipboard' 'Copy to clipboard'
) )
@@ -180,11 +182,10 @@ describe('DataView.vue', () => {
// Wait untill prepareCopy is finished // Wait untill prepareCopy is finished
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0] await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
await nextTick()
await nextTick() await nextTick()
// The dialog is shown... // The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
// ... with Ready message... // ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('Image is ready') expect(wrapper.find('.dialog-body').text()).to.equal('Image is ready')
@@ -196,12 +197,13 @@ describe('DataView.vue', () => {
// The dialog is not shown... // The dialog is not shown...
await clock.tick(100) await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
wrapper.unmount() wrapper.unmount()
}) })
it('copy to clipboard less than 1 sec', async () => { it('copy to clipboard less than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'write').resolves() sinon.stub(window.navigator.clipboard, 'write').resolves()
sinon.stub(cIo, 'copyImage')
const clock = sinon.useFakeTimers() const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, { const wrapper = mount(DataView, {
attachTo: document.body, attachTo: document.body,
@@ -229,7 +231,7 @@ describe('DataView.vue', () => {
await nextTick() await nextTick()
// The dialog is not shown... // The dialog is not shown...
await clock.tick(100) await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is called // copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true) expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount() wrapper.unmount()
@@ -270,7 +272,7 @@ describe('DataView.vue', () => {
// The dialog is not shown... // The dialog is not shown...
await clock.tick(100) await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is not called // copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false) expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount() wrapper.unmount()

View File

@@ -78,7 +78,7 @@ describe('RunResult.vue', () => {
await nextTick() await nextTick()
// The dialog is shown... // The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain( expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Copy to clipboard' 'Copy to clipboard'
) )
@@ -91,7 +91,7 @@ describe('RunResult.vue', () => {
await nextTick() await nextTick()
// The dialog is shown... // The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
// ... with Ready message... // ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready') expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
@@ -104,7 +104,7 @@ describe('RunResult.vue', () => {
// The dialog is not shown... // The dialog is not shown...
await clock.tick(100) await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
wrapper.unmount() wrapper.unmount()
}) })
@@ -143,7 +143,7 @@ describe('RunResult.vue', () => {
// The dialog is not shown... // The dialog is not shown...
await clock.tick(100) await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is called // copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true) expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount() wrapper.unmount()
@@ -188,7 +188,7 @@ describe('RunResult.vue', () => {
.trigger('click') .trigger('click')
// The dialog is not shown... // The dialog is not shown...
await clock.tick(100) await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false) expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is not called // copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false) expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount() wrapper.unmount()

View File

@@ -5,6 +5,9 @@ import mutations from '@/store/mutations'
import { createStore } from 'vuex' import { createStore } from 'vuex'
import Tabs from '@/views/MainView/Workspace/Tabs' import Tabs from '@/views/MainView/Workspace/Tabs'
import eventBus from '@/lib/eventBus' import eventBus from '@/lib/eventBus'
import { nextTick } from 'vue'
import cIo from '@/lib/utils/clipboardIo'
import csv from '@/lib/csv'
describe('Tabs.vue', () => { describe('Tabs.vue', () => {
let clock let clock
@@ -46,7 +49,7 @@ describe('Tabs.vue', () => {
id: 1, id: 1,
name: 'foo', name: 'foo',
query: 'select * from foo', query: 'select * from foo',
chart: [], viewType: 'chart',
isSaved: true isSaved: true
}, },
{ {
@@ -54,7 +57,7 @@ describe('Tabs.vue', () => {
name: null, name: null,
tempName: 'Untitled', tempName: 'Untitled',
query: '', query: '',
chart: [], viewType: 'chart',
isSaved: false isSaved: false
} }
], ],
@@ -97,7 +100,7 @@ describe('Tabs.vue', () => {
id: 1, id: 1,
name: 'foo', name: 'foo',
query: 'select * from foo', query: 'select * from foo',
chart: [], viewType: 'chart',
isSaved: true isSaved: true
}, },
{ {
@@ -105,7 +108,7 @@ describe('Tabs.vue', () => {
name: null, name: null,
tempName: 'Untitled', tempName: 'Untitled',
query: '', query: '',
chart: [], viewType: 'chart',
isSaved: false isSaved: false
} }
], ],
@@ -436,7 +439,7 @@ describe('Tabs.vue', () => {
id: 1, id: 1,
name: 'foo', name: 'foo',
query: 'select * from foo', query: 'select * from foo',
chart: [], viewType: 'chart',
isSaved: true isSaved: true
}, },
{ {
@@ -444,7 +447,7 @@ describe('Tabs.vue', () => {
name: null, name: null,
tempName: 'Untitled', tempName: 'Untitled',
query: '', query: '',
chart: [], viewType: 'chart',
isSaved: false isSaved: false
} }
], ],
@@ -477,7 +480,7 @@ describe('Tabs.vue', () => {
id: 1, id: 1,
name: 'foo', name: 'foo',
query: 'select * from foo', query: 'select * from foo',
chart: [], viewType: 'chart',
isSaved: true isSaved: true
} }
], ],
@@ -501,4 +504,216 @@ describe('Tabs.vue', () => {
expect(event.preventDefault.calledOnce).to.equal(false) expect(event.preventDefault.calledOnce).to.equal(false)
wrapper.unmount() wrapper.unmount()
}) })
it('Copy image to clipboard dialog works in the context of the tab', async () => {
// mock store state - 2 inquiries open
const state = {
tabs: [
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: undefined,
layout: {
sqlEditor: 'above',
table: 'hidden',
dataView: 'bottom'
},
isSaved: true
},
{
id: 2,
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
viewOptions: undefined,
layout: {
sqlEditor: 'above',
table: 'hidden',
dataView: 'bottom'
},
isSaved: false
}
],
currentTabId: 2
}
const store = createStore({ state })
sinon.stub(cIo, 'copyImage')
// mount the component
const wrapper = mount(Tabs, {
attachTo: document.body,
global: {
stubs: { teleport: true, transition: true, RouterLink: true },
plugins: [store]
}
})
const firstTabDataView = wrapper
.findAllComponents({ name: 'Tab' })[0]
.findComponent({ name: 'DataView' })
const secondTabDataView = wrapper
.findAllComponents({ name: 'Tab' })[1]
.findComponent({ name: 'DataView' })
// Stub prepareCopy method so it takes long and copy dialog will be shown
sinon
.stub(firstTabDataView.vm.$refs.viewComponent, 'prepareCopy')
.callsFake(async () => {
await clock.tick(5000)
return 'prepareCopy result in tab 1'
})
sinon
.stub(secondTabDataView.vm.$refs.viewComponent, 'prepareCopy')
.callsFake(async () => {
await clock.tick(5000)
return 'prepareCopy result in tab 2'
})
// Click Copy to clipboard button in the second tab
const copyBtn = secondTabDataView.findComponent({
ref: 'copyToClipboardBtn'
})
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Copy to clipboard'
)
// Switch to microtasks (let prepareCopy run)
await clock.tick(0)
// Wait untill prepareCopy is finished
await secondTabDataView.vm.$refs.viewComponent.prepareCopy.returnValues[0]
await nextTick()
// Click copy button in the dialog
await wrapper
.find('.dialog-buttons-container button.primary')
.trigger('click')
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyImage is called with prepare copy result calculated in tab 2, not null
// i.e. the dialog works in the tab 2 context
expect(
cIo.copyImage.calledOnceWith('prepareCopy result in tab 2')
).to.equal(true)
wrapper.unmount()
})
it('Copy CSV to clipboard dialog works in the context of the tab', async () => {
// mock store state - 2 inquiries open
const state = {
tabs: [
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: undefined,
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
result: {
columns: ['id', 'name'],
values: {
id: [1, 2, 3],
name: ['Gryffindor', 'Hufflepuff']
}
},
isSaved: true
},
{
id: 2,
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
viewOptions: undefined,
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
result: {
columns: ['name', 'points'],
values: {
name: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
points: [100, 90, 95, 80]
}
},
isSaved: false
}
],
currentTabId: 2
}
const store = createStore({ state })
sinon.stub(cIo, 'copyText')
// mount the component
const wrapper = mount(Tabs, {
attachTo: document.body,
global: {
stubs: { teleport: true, transition: true, RouterLink: true },
plugins: [store]
}
})
const secondTabRunResult = wrapper
.findAllComponents({ name: 'Tab' })[1]
.findComponent({ name: 'RunResult' })
// Stub prepareCopy method so it takes long and copy dialog will be shown
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
return 'csv serialize result'
})
// Click Copy to clipboard button in the second tab
const copyBtn = secondTabRunResult.findComponent({
ref: 'copyToClipboardBtn'
})
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Copy to clipboard'
)
// Switch to microtasks (let prepareCopy run)
await clock.tick(0)
await nextTick()
// Click copy button in the dialog
await wrapper
.find('.dialog-buttons-container button.primary')
.trigger('click')
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyText is called with 'csv serialize result' calculated in tab 2, not null
// i.e. the dialog works in the tab 2 context
expect(
cIo.copyText.calledOnceWith(
'csv serialize result',
'CSV copied to clipboard successfully'
)
).to.equal(true)
wrapper.unmount()
})
}) })