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

11 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
lana-k
12fa0749b1 Update package.json 2025-07-30 23:27:35 +02:00
saaj
75bf849823 Build SQLite 3.50.3 (#124)
* Build SQLite 3.50.3

* Update pivot_vtab, base in Dockerfile.test, fix test after SQLite 3.47

* Update CI image for tests
2025-07-30 23:26:22 +02:00
36 changed files with 1235 additions and 19285 deletions

View File

@@ -11,7 +11,7 @@ on:
jobs:
test:
name: Run tests
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v2
- name: Use Node.js
@@ -21,8 +21,9 @@ jobs:
- name: Install browsers
run: |
export DEBIAN_FRONTEND=noninteractive
sudo add-apt-repository -y ppa:mozillateam/ppa
sudo apt-get update
sudo apt-get install -y chromium-browser firefox
sudo apt-get install -y chromium-browser firefox-esr
- name: Update npm
run: npm install -g npm@10

View File

@@ -3,7 +3,7 @@
# docker build -t sqliteviz/test -f Dockerfile.test .
#
FROM node:12.22-buster
FROM node:12.22-bullseye
RUN set -ex; \
apt update; \

View File

@@ -10,7 +10,7 @@ from pathlib import Path
from urllib import request
amalgamation_url = 'https://sqlite.org/2023/sqlite-amalgamation-3410000.zip'
amalgamation_url = 'https://sqlite.org/2025/sqlite-amalgamation-3500300.zip'
# Extension-functions
# ===================
@@ -22,15 +22,15 @@ contrib_functions_url = 'https://sqlite.org/contrib/download/extension-functions
extension_urls = (
# Miscellaneous extensions
# ========================
('https://sqlite.org/src/raw/8d79354f?at=series.c', 'sqlite3_series_init'),
('https://sqlite.org/src/raw/dbfd8543?at=closure.c', 'sqlite3_closure_init'),
('https://sqlite.org/src/raw/e212edb2?at=series.c', 'sqlite3_series_init'),
('https://sqlite.org/src/raw/5559daf1?at=closure.c', 'sqlite3_closure_init'),
('https://sqlite.org/src/raw/5bb2264c?at=uuid.c', 'sqlite3_uuid_init'),
('https://sqlite.org/src/raw/5853b0e5?at=regexp.c', 'sqlite3_regexp_init'),
('https://sqlite.org/src/raw/b9086e22?at=percentile.c', 'sqlite3_percentile_init'),
('https://sqlite.org/src/raw/09f967dc?at=decimal.c', 'sqlite3_decimal_init'),
('https://sqlite.org/src/raw/388e7f23?at=regexp.c', 'sqlite3_regexp_init'),
('https://sqlite.org/src/raw/72e05a21?at=percentile.c', 'sqlite3_percentile_init'),
('https://sqlite.org/src/raw/228d47e9?at=decimal.c', 'sqlite3_decimal_init'),
# Third-party extension
# =====================
('https://github.com/jakethaw/pivot_vtab/raw/9323ef93/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
('https://github.com/jakethaw/pivot_vtab/raw/e7705f34/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
('https://github.com/nalgeon/sqlean/raw/95e8d21a/src/pearson.c', 'sqlite3_pearson_init'),
# Third-party extension with own dependencies
# ===========================================

File diff suppressed because one or more lines are too long

Binary file not shown.

17598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sqliteviz",
"version": "0.26.0",
"version": "0.27.0",
"license": "Apache-2.0",
"private": true,
"type": "module",
@@ -18,9 +18,6 @@
"codemirror-editor-vue3": "^2.8.0",
"core-js": "^3.6.5",
"dataurl-to-blob": "^0.0.1",
"graphology": "^0.26.0",
"graphology-layout": "^0.6.1",
"graphology-layout-forceatlas2": "^0.10.1",
"html2canvas": "^1.1.4",
"jquery": "^3.6.0",
"nanoid": "^3.1.12",
@@ -31,8 +28,6 @@
"react": "^16.14.0",
"react-chart-editor": "^0.46.1",
"react-dom": "^16.14.0",
"seedrandom": "^3.0.5",
"sigma": "^3.0.1",
"sql.js": "file:./lib/sql-js",
"tiny-emitter": "^2.1.0",
"veaury": "^2.5.1",

View File

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

View File

@@ -1,77 +0,0 @@
<template>
<Field
label="Hierarchy attributes"
fieldContainerClassName="multiselect-field"
>
<multiselect
:modelValue="modelValue.hierarchyAttributes"
class="sqliteviz-select"
:options="keyOptions"
:multiple="true"
:hideSelected="true"
:closeOnSelect="true"
:showLabels="false"
:max="keyOptions.length"
placeholder=""
openDirection="bottom"
@update:model-value="update('hierarchyAttributes', $event)"
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<template #placeholder>Select an Option</template>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
</Field>
<Field label="Seed value">
<NumericInput
:value="modelValue.seedValue"
@update="update('seedValue', $event)"
/>
</Field>
</template>
<script>
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import Multiselect from 'vue-multiselect'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
NumericInput: applyPureReactInVue(NumericInput),
Dropdown: applyPureReactInVue(Dropdown),
Multiselect
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>
<style scoped>
:deep(.sqliteviz-select.multiselect--active .multiselect__input) {
width: 100% !important;
}
:deep(.multiselect-field .field__widget > *) {
flex-grow: 1 !important;
}
</style>

View File

@@ -1,125 +0,0 @@
<template>
<Field label="Adjust sizes">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.adjustSizes"
@option-change="update('adjustSizes', $event)"
/>
</Field>
<Field label="Barnes-Hut optimize">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.barnesHutOptimize"
@option-change="update('barnesHutOptimize', $event)"
/>
</Field>
<Field v-show="modelValue.barnesHutOptimize" label="Barnes-Hut Theta">
<NumericInput
:value="modelValue.barnesHutTheta"
@update="update('barnesHutTheta', $event)"
/>
</Field>
<Field label="Gravity">
<NumericInput
:value="modelValue.gravity"
@update="update('gravity', $event)"
/>
</Field>
<Field label="Strong gravity mode">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.strongGravityMode"
@option-change="update('strongGravityMode', $event)"
/>
</Field>
<Field label="Noack's LinLog model">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.linLogMode"
@option-change="update('linLogMode', $event)"
/>
</Field>
<Field label="Out bound attraction distribution">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.outboundAttractionDistribution"
@option-change="update('outboundAttractionDistribution', $event)"
/>
</Field>
<Field label="Scaling ratio">
<NumericInput
:value="modelValue.scalingRatio"
@update="update('scalingRatio', $event)"
/>
</Field>
<Field label="Slow down">
<NumericInput
:value="modelValue.slowDown"
:min="1"
@update="update('slowDown', $event)"
/>
</Field>
<Field label="Edge weight influence">
<NumericInput
:value="modelValue.edgeWeightInfluence"
@update="update('edgeWeightInfluence', $event)"
/>
</Field>
<Field label="Edge weight">
<Dropdown
:options="keyOptions"
:value="modelValue.weightSource"
@change="update('weightSource', $event)"
/>
</Field>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
booleanOptions: markRaw([
{ label: 'Yes', value: true },
{ label: 'No', value: false }
])
}
},
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -1,34 +0,0 @@
<template>
<Field label="Seed value">
<NumericInput
:value="modelValue.seedValue"
@update="update('seedValue', $event)"
/>
</Field>
</template>
<script>
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
NumericInput: applyPureReactInVue(NumericInput)
},
props: {
modelValue: Object
},
emits: ['update:modelValue'],
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

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

View File

@@ -1,40 +0,0 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 4C5 5.10457 4.10457 6 3 6C1.89543 6 1 5.10457 1 4C1 2.89543 1.89543 2 3 2C4.10457 2 5 2.89543 5 4Z"
fill="#A2B1C6"
/>
<path
d="M17 7.5C17 8.88071 15.8807 10 14.5 10C13.1193 10 12 8.88071 12 7.5C12 6.11929 13.1193 5 14.5 5C15.8807 5 17 6.11929 17 7.5Z"
fill="#A2B1C6"
/>
<path
d="M8 13.5C8 14.8807 6.88071 16 5.5 16C4.11929 16 3 14.8807 3 13.5C3 12.1193 4.11929 11 5.5 11C6.88071 11 8 12.1193 8 13.5Z"
fill="#A2B1C6"
/>
<path
d="M2.93128 5.31436L3.90527 5.08778L5.48693 11.8867L4.51294 12.1133L2.93128 5.31436Z"
fill="#A2B1C6"
/>
<path
d="M12.9447 7.79159L13.5548 8.58392L7.30516 13.3962L6.69507 12.6038L12.9447 7.79159Z"
fill="#A2B1C6"
/>
<path
d="M14.1316 6.51712L3.13166 3.51723L2.86844 4.48202L13.8684 7.48191L14.1316 6.51712Z"
fill="#A2B1C6"
/>
</svg>
</template>
<script>
export default {
name: 'GraphIcon'
}
</script>

View File

@@ -1,20 +0,0 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4C3 3.44772 3.44772 3 4 3H14C14.5523 3 15 3.44772 15 4V14C15 14.5523 14.5523 15 14 15H4C3.44772 15 3 14.5523 3 14V4Z"
fill="#A2B1C6"
/>
</svg>
</template>
<script>
export default {
name: 'StopIcon'
}
</script>

View File

@@ -1,52 +0,0 @@
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { localizeString } from 'react-chart-editor/lib'
class EditorControls extends Component {
constructor(props, context) {
super(props, context)
this.localize = key =>
localizeString(this.props.dictionaries || {}, this.props.locale, key)
}
getChildContext() {
return {
dictionaries: this.props.dictionaries || {},
localize: this.localize,
locale: this.props.locale
}
}
render() {
return (
<div
className={
'editor_controls plotly-editor--theme-provider' +
`${this.props.className ? ` ${this.props.className}` : ''}`
}
>
{this.props.children}
</div>
)
}
}
EditorControls.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
dictionaries: PropTypes.object,
locale: PropTypes.string
}
EditorControls.defaultProps = {
locale: 'en'
}
EditorControls.childContextTypes = {
dictionaries: PropTypes.object,
locale: PropTypes.string,
localize: PropTypes.func
}
export default EditorControls

View File

@@ -1,364 +0,0 @@
import { nanoid } from 'nanoid'
import { COLOR_PICKER_CONSTANTS } from 'react-colorscales'
import tinycolor from 'tinycolor2'
const TYPE_NODE = 0
const TYPE_EDGE = 1
const DEFAULT_SCALE = COLOR_PICKER_CONSTANTS.DEFAULT_SCALE
export function buildNodes(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0] || 'doc'
const { objectType, nodeId } = options.structure
if (objectType && nodeId) {
const nodes = dataSources[docColumn]
.map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_NODE)
nodes.forEach(node => {
graph.addNode(node[nodeId], {
data: node
})
})
}
}
export function buildEdges(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0] || 'doc'
const { objectType, edgeSource, edgeTarget } = options.structure
if (objectType && edgeSource && edgeTarget) {
const edges = dataSources[docColumn]
.map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_EDGE)
edges.forEach(edge => {
const source = edge[edgeSource]
const target = edge[edgeTarget]
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.addEdge(source, target, {
data: edge
})
}
})
}
}
export function updateNodes(graph, attributeUpdates) {
const changeMethods = []
if (attributeUpdates.label) {
changeMethods.push(getUpdateLabelMethod(attributeUpdates.label))
}
if (attributeUpdates.size) {
changeMethods.push(getUpdateSizeMethod(graph, attributeUpdates.size))
}
if (attributeUpdates.color) {
changeMethods.push(getUpdateNodeColorMethod(graph, attributeUpdates.color))
}
graph.forEachNode(nodeId => {
graph.updateNode(nodeId, attributes => {
const newAttributes = { ...attributes }
changeMethods.forEach(method => method(newAttributes, nodeId))
return newAttributes
})
})
}
export function updateEdges(graph, attributeUpdates) {
const changeMethods = []
if (attributeUpdates.label) {
changeMethods.push(getUpdateLabelMethod(attributeUpdates.label))
}
if (attributeUpdates.size) {
changeMethods.push(getUpdateSizeMethod(graph, attributeUpdates.size))
}
if (attributeUpdates.color) {
changeMethods.push(getUpdateEdgeColorMethod(graph, attributeUpdates.color))
}
if ('showDirection' in attributeUpdates) {
changeMethods.push(
attributes =>
(attributes.type = attributeUpdates.showDirection ? 'arrow' : 'line')
)
}
graph.forEachEdge((edgeId, attributes, source, target) => {
graph.updateEdge(source, target, attributes => {
const newAttributes = { ...attributes }
changeMethods.forEach(method => method(newAttributes, edgeId))
return newAttributes
})
})
}
function getUpdateLabelMethod(labelSettings) {
const { source } = labelSettings
return attributes => {
const label = attributes.data[source] ?? ''
attributes.label = label.toString()
}
}
function getUpdateSizeMethod(graph, sizeSettings) {
const { type, value, source, scale, mode, min, method } = sizeSettings
if (type === 'constant') {
return attributes => (attributes.size = value)
} else if (type === 'variable') {
return getVariabledSizeMethod(mode, source, scale, min)
} else {
return (attributes, nodeId) =>
(attributes.size = Math.max(graph[method](nodeId) * scale, min))
}
}
function getDirectVariableColorUpdateMethod(source) {
return attributes =>
(attributes.color = tinycolor(attributes.data[source]).toHexString())
}
function getUpdateNodeColorMethod(graph, colorSettings) {
const {
type,
value,
source,
sourceUsage,
colorscale,
colorscaleDirection,
mode,
method
} = colorSettings
if (type === 'constant') {
return attributes => (attributes.color = value)
} else if (type === 'variable') {
return sourceUsage === 'map_to'
? getColorMethod(
graph,
mode,
(nodeId, attributes) => attributes.data[source],
colorscale,
colorscaleDirection,
getNodeValueScale
)
: getDirectVariableColorUpdateMethod(source)
} else {
return getColorMethod(
graph,
mode,
nodeId => graph[method](nodeId),
colorscale,
colorscaleDirection,
getNodeValueScale
)
}
}
function getUpdateEdgeColorMethod(graph, colorSettings) {
const {
type,
value,
source,
sourceUsage,
colorscale,
colorscaleDirection,
mode
} = colorSettings
if (type === 'constant') {
return attributes => (attributes.color = value)
} else {
return sourceUsage === 'map_to'
? getColorMethod(
graph,
mode,
(edgeId, attributes) => attributes.data[source],
colorscale,
colorscaleDirection,
getEdgeValueScale
)
: getDirectVariableColorUpdateMethod(source)
}
}
function getVariabledSizeMethod(mode, source, scale, min) {
if (mode === 'diameter') {
return attributes =>
(attributes.size = Math.max(
(attributes.data[source] / 2) * scale,
min / 2
))
} else if (mode === 'area') {
return attributes =>
(attributes.size = Math.max(
Math.sqrt((attributes.data[source] / 2) * scale),
min / 2
))
} else {
return attributes =>
(attributes.size = Math.max(attributes.data[source] * scale, min))
}
}
function getColorMethod(
graph,
mode,
sourceGetter,
selectedColorscale,
colorscaleDirection,
valueScaleGetter
) {
const valueScale = valueScaleGetter(graph, sourceGetter)
let colorscale = selectedColorscale || DEFAULT_SCALE
if (colorscaleDirection === 'reversed') {
colorscale = [...colorscale].reverse()
}
if (mode === 'categorical') {
const colorMap = Object.fromEntries(
valueScale.map((value, index) => [
value,
colorscale[index % colorscale.length]
])
)
return (attributes, nodeId) => {
const category = sourceGetter(nodeId, attributes)
attributes.color = colorMap[category]
}
} else {
const min = valueScale[0]
const max = valueScale[valueScale.length - 1]
const normalizedColorscale = colorscale.map((color, index) => [
index / (colorscale.length - 1),
tinycolor(color).toRgb()
])
return (attributes, nodeId) => {
const value = sourceGetter(nodeId, attributes)
const normalizedValue = (value - min) / (max - min)
if (isNaN(normalizedValue)) {
return
}
const exactMatch = normalizedColorscale.find(
([value]) => value === normalizedValue
)
if (exactMatch) {
attributes.color = tinycolor(exactMatch[1]).toHexString()
return
}
const rightColorIndex = normalizedColorscale.findIndex(
([value]) => value >= normalizedValue
)
const leftColorIndex = (rightColorIndex || 1) - 1
const right = normalizedColorscale[rightColorIndex]
const left = normalizedColorscale[leftColorIndex]
const interpolationFactor =
(normalizedValue - left[0]) / (right[0] - left[0])
const r0 = left[1].r
const g0 = left[1].g
const b0 = left[1].b
const r1 = right[1].r
const g1 = right[1].g
const b1 = right[1].b
attributes.color = tinycolor({
r: r0 + interpolationFactor * (r1 - r0),
g: g0 + interpolationFactor * (g1 - g0),
b: b0 + interpolationFactor * (b1 - b0)
}).toHexString()
}
}
}
function getNodeValueScale(graph, sourceGetter) {
const scaleSet = graph.reduceNodes((res, nodeId, attributes) => {
res.add(sourceGetter(nodeId, attributes))
return res
}, new Set())
return Array.from(scaleSet).sort((a, b) => a - b)
}
function getEdgeValueScale(graph, sourceGetter) {
const scaleSet = graph.reduceEdges((res, edgeId, attributes) => {
res.add(sourceGetter(edgeId, attributes))
return res
}, new Set())
return Array.from(scaleSet).sort((a, b) => a - b)
}
export function getOptionsFromDataSources(dataSources) {
if (!dataSources) {
return []
}
return Object.keys(dataSources).map(name => ({
value: name,
label: name
}))
}
export function getOptionsForSave(state, dataSources) {
// we don't need to save the data, only settings
// so we modify state.data using dereference
const stateCopy = JSON.parse(JSON.stringify(state))
const emptySources = {}
for (const key in dataSources) {
emptySources[key] = []
}
dereference.default(stateCopy.data, emptySources)
return stateCopy
}
export async function getImageDataUrl(element, type) {
const chartElement = element.querySelector('.js-plotly-plot')
return await plotly.toImage(chartElement, {
format: type,
width: null,
height: null
})
}
export function getChartData(element) {
const chartElement = element.querySelector('.js-plotly-plot')
return {
data: chartElement.data,
layout: chartElement.layout
}
}
export function getHtml(options) {
const chartId = nanoid()
return `
<script src="https://cdn.plot.ly/plotly-latest.js" charset="UTF-8"></script>
<div id="${chartId}"></div>
<script>
const el = document.getElementById("${chartId}")
let timeout
function debounceResize() {
clearTimeout(timeout)
timeout = setTimeout(() => {
var r = el.getBoundingClientRect()
Plotly.relayout(el, {width: r.width, height: r.height})
}, 200)
}
const resizeObserver = new ResizeObserver(debounceResize)
resizeObserver.observe(el)
Plotly.newPlot(el, ${JSON.stringify(options.data)}, ${JSON.stringify(options.layout)})
</script>
`
}
export default {
getOptionsFromDataSources,
getOptionsForSave,
getImageDataUrl,
getHtml,
getChartData
}

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ export default {
},
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
if (id && state.currentTabId === oldId) {
@@ -36,6 +37,9 @@ export default {
// Saved inquiry is not predefined
delete tab.isPredefined
}
if (updatedAt) {
tab.updatedAt = updatedAt
}
},
deleteTab(state, tab) {

View File

@@ -10,14 +10,22 @@
</div>
<div id="nav-buttons">
<button
v-show="currentInquiry && $route.path === '/workspace'"
v-show="currentInquiryTab && $route.path === '/workspace'"
id="save-btn"
class="primary"
:disabled="isSaved"
@click="checkInquiryBeforeSave"
@click="onSave(false)"
>
Save
</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">
Create
</button>
@@ -45,7 +53,34 @@
</div>
<div class="dialog-buttons-container">
<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>
</modal>
</nav>
@@ -73,25 +108,28 @@ export default {
}
},
computed: {
currentInquiry() {
inquiries() {
return this.$store.state.inquiries
},
currentInquiryTab() {
return this.$store.state.currentTab
},
isSaved() {
return this.currentInquiry && this.currentInquiry.isSaved
return this.currentInquiryTab && this.currentInquiryTab.isSaved
},
isPredefined() {
return this.currentInquiry && this.currentInquiry.isPredefined
return this.currentInquiryTab && this.currentInquiryTab.isPredefined
},
runDisabled() {
return (
this.currentInquiry &&
(!this.$store.state.db || !this.currentInquiry.query)
this.currentInquiryTab &&
(!this.$store.state.db || !this.currentInquiryTab.query)
)
}
},
created() {
eventBus.$on('createNewInquiry', this.createNewInquiry)
eventBus.$on('saveInquiry', this.checkInquiryBeforeSave)
eventBus.$on('saveInquiry', this.onSave)
document.addEventListener('keydown', this._keyListener)
},
beforeUnmount() {
@@ -109,44 +147,73 @@ export default {
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()
}
this.$modal.hide('save')
this.$modal.hide('inquiry-conflict')
eventBus.$off('inquirySaved')
},
async saveInquiry() {
const isNeedName = storedInquiries.isTabNeedName(this.currentInquiry)
if (isNeedName && !this.name) {
onSave(skipConcurrentEditingCheck = false) {
if (storedInquiries.isTabNeedName(this.currentInquiryTab)) {
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"
return
}
const dataSet = this.currentInquiry.result
const tabView = this.currentInquiry.view
this.saveInquiry()
},
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
const value = await this.$store.dispatch('saveInquiry', {
inquiryTab: this.currentInquiry,
inquiryTab: this.currentInquiryTab,
newName: this.name
})
// Update tab in store
this.$store.commit('updateTab', {
tab: this.currentInquiry,
tab: this.currentInquiryTab,
newValues: {
name: value.name,
id: value.id,
query: value.query,
viewType: value.viewType,
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.
// That's why we need to restore data and view
this.$nextTick(() => {
this.currentInquiry.result = dataSet
this.currentInquiry.view = tabView
this.currentInquiryTab.result = dataSet
this.currentInquiryTab.view = tabView
})
// Hide dialog
// Hide dialogs
this.$modal.hide('save')
this.$modal.hide('inquiry-conflict')
this.errorMsg = null
this.name = ''
// Signal about saving
eventBus.$emit('inquirySaved')
events.send('inquiry.save')
events.send(eventName)
},
_keyListener(e) {
if (this.$route.path === '/workspace') {
@@ -173,19 +243,25 @@ export default {
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!this.runDisabled) {
this.currentInquiry.execute()
this.currentInquiryTab.execute()
}
return
}
// Save inquiry Ctrl+S
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
if (e.key === 's' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
if (!this.isSaved) {
this.checkInquiryBeforeSave()
this.onSave()
}
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
if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {

View File

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

View File

@@ -1,734 +0,0 @@
<template>
<div class="plotly_editor">
<GraphEditorControls>
<PanelMenuWrapper>
<Panel group="Structure" name="Graph">
<Fold name="Graph">
<Field>Choose keys explanation...</Field>
<Field label="Object type">
<Dropdown
:options="keysOptions"
:value="settings.structure.objectType"
@change="updateStructure('objectType', $event)"
/>
<Field>0 - node; 1 - edge</Field>
</Field>
<Field label="Node Id">
<Dropdown
:options="keysOptions"
:value="settings.structure.nodeId"
@change="updateStructure('nodeId', $event)"
/>
</Field>
<Field label="Edge source">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeSource"
@change="updateStructure('edgeSource', $event)"
/>
</Field>
<Field label="Edge target">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeTarget"
@change="updateStructure('edgeTarget', $event)"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Nodes">
<Fold name="Nodes">
<Field label="Label">
<Dropdown
:options="keysOptions"
:value="settings.style.nodes.label.source"
@change="updateNodes('label.source', $event)"
/>
</Field>
<Field label="Size">
<RadioBlocks
:options="nodeSizeTypeOptions"
:activeOption="settings.style.nodes.size.type"
@option-change="updateNodes('size.type', $event)"
/>
<Field>
<NumericInput
v-if="settings.style.nodes.size.type === 'constant'"
:value="settings.style.nodes.size.value"
:min="1"
@update="updateNodes('size.value', $event)"
/>
<Dropdown
v-if="settings.style.nodes.size.type === 'variable'"
:options="keysOptions"
:value="settings.style.nodes.size.source"
@change="updateNodes('size.source', $event)"
/>
<Dropdown
v-if="settings.style.nodes.size.type === 'calculated'"
:options="nodeCalculatedSizeMethodOptions"
:value="settings.style.nodes.size.method"
@change="updateNodes('size.method', $event)"
/>
</Field>
</Field>
<template v-if="settings.style.nodes.size.type !== 'constant'">
<Field label="Size scale">
<NumericInput
:value="settings.style.nodes.size.scale"
@update="updateNodes('size.scale', $event)"
/>
</Field>
<Field label="Size mode">
<RadioBlocks
:options="nodeSizeModeOptions"
:activeOption="settings.style.nodes.size.mode"
@option-change="updateNodes('size.mode', $event)"
/>
</Field>
<Field label="Minimum size">
<NumericInput
:value="settings.style.nodes.size.min"
@update="updateNodes('size.min', $event)"
/>
</Field>
</template>
<Field label="Color">
<RadioBlocks
:options="nodeColorTypeOptions"
:activeOption="settings.style.nodes.color.type"
@option-change="updateNodes('color.type', $event)"
/>
<Field v-if="settings.style.nodes.color.type === 'constant'">
<ColorPicker
:selectedColor="settings.style.nodes.color.value"
@color-change="updateNodes('color.value', $event)"
/>
</Field>
<template v-else>
<Field>
<Dropdown
v-if="settings.style.nodes.color.type === 'variable'"
:options="keysOptions"
:value="settings.style.nodes.color.source"
@change="updateNodes('color.source', $event)"
/>
<Dropdown
v-if="settings.style.nodes.color.type === 'calculated'"
:options="nodeCalculatedColorMethodOptions"
:value="settings.style.nodes.color.method"
@change="updateNodes('color.method', $event)"
/>
</Field>
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="settings.style.nodes.color.sourceUsage"
@option-change="updateNodes('color.sourceUsage', $event)"
/>
</Field>
<Field
v-if="settings.style.nodes.color.sourceUsage === 'map_to'"
>
<ColorscalePicker
:selected="settings.style.nodes.color.colorscale"
className="colorscale-picker"
@colorscale-change="updateNodes('color.colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field
v-if="settings.style.nodes.color.type !== 'constant'"
label="Color as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="settings.style.nodes.color.mode"
@option-change="updateNodes('color.mode', $event)"
/>
</Field>
<Field
v-if="settings.style.nodes.color.type !== 'constant'"
label="Colorscale direction"
>
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="settings.style.nodes.color.colorscaleDirection"
@option-change="
updateNodes('color.colorscaleDirection', $event)
"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Edges">
<Fold name="Edges">
<Field label="Direction">
<RadioBlocks
:options="visibilityOptions"
:activeOption="settings.style.edges.showDirection"
@option-change="updateEdges('showDirection', $event)"
/>
</Field>
<Field label="Label">
<Dropdown
:options="keysOptions"
:value="settings.style.edges.label.source"
@change="updateEdges('label.source', $event)"
/>
</Field>
<Field label="Size">
<RadioBlocks
:options="edgeSizeTypeOptions"
:activeOption="settings.style.edges.size.type"
@option-change="updateEdges('size.type', $event)"
/>
<Field>
<NumericInput
v-if="settings.style.edges.size.type === 'constant'"
:value="settings.style.edges.size.value"
:min="1"
@update="updateEdges('size.value', $event)"
/>
<Dropdown
v-if="settings.style.edges.size.type === 'variable'"
:options="keysOptions"
:value="settings.style.edges.size.source"
@change="updateEdges('size.source', $event)"
/>
</Field>
</Field>
<template v-if="settings.style.edges.size.type !== 'constant'">
<Field label="Size scale">
<NumericInput
:value="settings.style.edges.size.scale"
@update="updateEdges('size.scale', $event)"
/>
</Field>
<Field label="Minimum size">
<NumericInput
:value="settings.style.edges.size.min"
@update="updateEdges('size.min', $event)"
/>
</Field>
</template>
<Field label="Color">
<RadioBlocks
:options="edgeColorTypeOptions"
:activeOption="settings.style.edges.color.type"
@option-change="updateEdges('color.type', $event)"
/>
<Field v-if="settings.style.edges.color.type === 'constant'">
<ColorPicker
:selectedColor="settings.style.edges.color.value"
@color-change="updateEdges('color.value', $event)"
/>
</Field>
<template v-else>
<Field>
<Dropdown
v-if="settings.style.edges.color.type === 'variable'"
:options="keysOptions"
:value="settings.style.edges.color.source"
@change="updateEdges('color.source', $event)"
/>
</Field>
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="settings.style.edges.color.sourceUsage"
@option-change="updateEdges('color.sourceUsage', $event)"
/>
</Field>
<Field
v-if="settings.style.edges.color.sourceUsage === 'map_to'"
>
<ColorscalePicker
:selected="settings.style.edges.color.colorscale"
className="colorscale-picker"
@colorscale-change="updateEdges('color.colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field
v-if="settings.style.edges.color.type !== 'constant'"
label="Color as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="settings.style.edges.color.mode"
@option-change="updateEdges('color.mode', $event)"
/>
</Field>
<Field
v-if="settings.style.edges.color.type !== 'constant'"
label="Colorscale direction"
>
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="settings.style.edges.color.colorscaleDirection"
@option-change="
updateEdges('color.colorscaleDirection', $event)
"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Layout">
<Fold name="Layout">
<Field label="Algorithm">
<Dropdown
:options="layoutOptions"
:value="settings.layout.type"
@change="updateLayout($event)"
/>
</Field>
<component
:is="layoutSettingsComponentMap[settings.layout.type]"
v-if="settings.layout.type !== 'circular'"
v-model="settings.layout.options"
:keyOptions="keysOptions"
@update:model-value="updateLayout(settings.layout.type)"
/>
<Field v-if="settings.layout.type === 'forceAtlas2'">
<Button variant="primary" @click="toggleFA2Layout">
<template #node:icon>
<div
:style="{
padding: '0 3px'
}"
>
<RunIcon v-if="!fa2Running" />
<StopIcon v-else /></div
></template>
{{ fa2Running ? 'Stop' : 'Start' }}
</Button>
</Field>
</Fold>
</Panel>
</PanelMenuWrapper>
</GraphEditorControls>
<div
ref="graph"
:style="{
height: '100%',
width: '100%'
}"
/>
</div>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import GraphEditorControls from '@/lib/GraphEditorControls.jsx'
import { PanelMenuWrapper, Panel, Fold, Section } from 'react-chart-editor'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import Button from 'react-chart-editor/lib/components/widgets/Button'
import ColorscalePicker from 'react-chart-editor/lib/components/widgets/ColorscalePicker'
import ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
import Field from 'react-chart-editor/lib/components/fields/Field'
import RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue'
import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSettings.vue'
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue'
import 'react-chart-editor/lib/react-chart-editor.css'
import FA2Layout from 'graphology-layout-forceatlas2/worker'
import forceAtlas2 from 'graphology-layout-forceatlas2'
import RunIcon from '@/components/svg/run.vue'
import StopIcon from '@/components/svg/stop.vue'
import {
buildNodes,
buildEdges,
updateNodes,
updateEdges
} from '@/lib/graphHelper'
import Graph from 'graphology'
import { circular, random, circlepack } from 'graphology-layout'
import Sigma from 'sigma'
import seedrandom from 'seedrandom'
export default {
components: {
GraphEditorControls: applyPureReactInVue(GraphEditorControls),
PanelMenuWrapper: applyPureReactInVue(PanelMenuWrapper),
Panel: applyPureReactInVue(Panel),
PanelSection: applyPureReactInVue(Section),
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
Fold: applyPureReactInVue(Fold),
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
ColorPicker: applyPureReactInVue(ColorPicker),
Button: applyPureReactInVue(Button),
RunIcon,
StopIcon,
RandomLayoutSettings,
CirclePackLayoutSettings
},
props: {
dataSources: Object
},
data() {
return {
graph: new Graph(),
renderer: null,
fa2Layout: null,
fa2Running: false,
nodeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
edgeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
nodeCalculatedSizeMethodOptions: markRaw([
{ label: 'Degree', value: 'degree' },
{ label: 'In degree', value: 'inDegree' },
{ label: 'Out degree', value: 'outDegree' }
]),
nodeSizeModeOptions: markRaw([
{ label: 'Area', value: 'area' },
{ label: 'Diameter', value: 'diameter' }
]),
nodeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
edgeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
nodeCalculatedColorMethodOptions: markRaw([
{ label: 'Degree', value: 'degree' },
{ label: 'In degree', value: 'inDegree' },
{ label: 'Out degree', value: 'outDegree' }
]),
сolorAsOptions: markRaw([
{ label: 'Continious', value: 'continious' },
{ label: 'Categorical', value: 'categorical' }
]),
сolorscaleDirections: markRaw([
{ label: 'Normal', value: 'normal' },
{ label: 'Recersed', value: 'reversed' }
]),
colorSourceUsageOptions: markRaw([
{ label: 'Direct', value: 'direct' },
{ label: 'Map to', value: 'map_to' }
]),
visibilityOptions: markRaw([
{ label: 'Show', value: true },
{ label: 'Hide', value: false }
]),
layoutOptions: markRaw([
{ label: 'Circular', value: 'circular' },
{ label: 'Random', value: 'random' },
{ label: 'Circle pack', value: 'circlepack' },
{ label: 'ForceAtlas2', value: 'forceAtlas2' }
]),
layoutSettingsComponentMap: markRaw({
random: RandomLayoutSettings,
circlepack: CirclePackLayoutSettings,
forceAtlas2: ForceAtlasLayoutSettings
}),
settings: {
structure: {
nodeId: null,
objectType: null,
edgeSource: null,
edgeTarget: null
},
style: {
nodes: {
size: {
type: 'constant',
value: 16,
source: null,
scale: 1,
mode: 'diameter',
method: 'degree',
min: 0
},
color: {
type: 'constant',
value: '#1F77B4',
source: null,
sourceUsage: 'map_to',
colorscale: null,
colorscaleDirection: 'normal',
method: 'degree',
mode: 'continious'
},
label: {
source: null
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2,
source: null,
scale: 1,
min: 0
},
color: {
type: 'constant',
value: '#a2b1c6',
source: null,
sourceUsage: 'map_to',
colorscale: null,
colorscaleDirection: 'normal',
mode: 'continious'
},
label: {
source: null
}
}
},
layout: {
type: 'circular',
options: null
}
},
layoutOptionsArchive: {
random: null,
circlepack: null,
forceAtlas2: null
}
}
},
computed: {
records() {
if (!this.dataSources) {
return []
}
return this.dataSources[Object.keys(this.dataSources)[0] || 'doc'].map(
json => JSON.parse(json)
)
},
keysOptions() {
if (!this.dataSources) {
return []
}
const keySet = this.records.reduce((result, currentRecord) => {
Object.keys(currentRecord).forEach(key => result.add(key))
return result
}, new Set())
return Array.from(keySet)
}
},
watch: {
dataSources() {
if (this.dataSources) {
this.buildGraph()
}
},
'settings.structure': {
deep: true,
handler() {
this.buildGraph()
}
}
},
methods: {
buildGraph() {
if (this.renderer) {
this.renderer.kill()
}
this.graph.clear()
buildNodes(this.graph, this.dataSources, this.settings)
buildEdges(this.graph, this.dataSources, this.settings)
// Apply visual settings
updateNodes(this.graph, this.settings.style.nodes)
updateEdges(this.graph, this.settings.style.edges)
circular.assign(this.graph)
this.renderer = new Sigma(this.graph, this.$refs.graph, {
renderEdgeLabels: true,
allowInvalidContainer: true
})
},
updateStructure(attributeName, value) {
this.settings.structure[attributeName] = value
},
updateNodes(attributeName, value) {
const attributePath = attributeName.split('.')
attributePath.reduce((result, current, index) => {
if (index === attributePath.length - 1) {
return (result[current] = value)
} else {
return result[current]
}
}, this.settings.style.nodes)
updateNodes(this.graph, {
[attributePath[0]]: this.settings.style.nodes[attributePath[0]]
})
},
updateEdges(attributeName, value) {
const attributePath = attributeName.split('.')
attributePath.reduce((result, current, index) => {
if (index === attributePath.length - 1) {
return (result[current] = value)
} else {
return result[current]
}
}, this.settings.style.edges)
updateEdges(this.graph, {
[attributePath[0]]: this.settings.style.edges[attributePath[0]]
})
},
updateLayout(layoutType) {
if (layoutType !== this.settings.layout.type) {
const prevLayout = this.settings.layout.type
this.layoutOptionsArchive[prevLayout] = this.settings.layout.options
this.settings.layout.options = this.layoutOptionsArchive[layoutType]
if (layoutType === 'forceAtlas2' && !this.settings.layout.options) {
const sensibleSettings = forceAtlas2.inferSettings(this.graph)
this.settings.layout.options = {
adjustSizes: false,
barnesHutOptimize: false,
barnesHutTheta: 0.5,
edgeWeightInfluence: 1,
gravity: 1,
linLogMode: false,
outboundAttractionDistribution: false,
scalingRatio: 1,
slowDown: 1,
strongGravityMode: false,
...sensibleSettings
}
} else if (layoutType === 'random' && !this.settings.layout.options) {
this.settings.layout.options = {
seedValue: 1
}
} else if (
layoutType === 'circlepack' &&
!this.settings.layout.options
) {
this.settings.layout.options = {
seedValue: 1
}
}
this.settings.layout.type = layoutType
}
if (layoutType === 'circular') {
circular.assign(this.graph)
return
}
if (layoutType === 'random') {
random.assign(this.graph, {
rng: seedrandom(this.settings.layout.options.seedValue || 1)
})
return
}
if (layoutType === 'circlepack') {
this.graph.forEachNode(nodeId => {
this.graph.updateNode(nodeId, attributes => {
const newAttributes = { ...attributes }
// Delete old hierarchy attributes
Object.keys(newAttributes)
.filter(key => key.startsWith('hierarchyAttribute'))
.forEach(
hierarchyAttributeKey =>
delete newAttributes[hierarchyAttributeKey]
)
// Set new hierarchy attributes
this.settings.layout.options.hierarchyAttributes?.forEach(
(hierarchyAttribute, index) => {
newAttributes['hierarchyAttribute' + index] =
attributes.data[hierarchyAttribute]
}
)
return newAttributes
})
})
circlepack.assign(this.graph, {
hierarchyAttributes:
this.settings.layout.options.hierarchyAttributes?.map(
(_, index) => 'hierarchyAttribute' + index
) || [],
rng: seedrandom(this.settings.layout.options.seedValue || 1)
})
return
}
if (layoutType === 'forceAtlas2') {
if (this.fa2Layout) {
this.fa2Layout.kill()
}
this.fa2Layout = markRaw(
new FA2Layout(this.graph, {
getEdgeWeight: (_, attr) =>
attr.data[this.settings.layout.options.weightSource || 'weight'],
settings: this.settings.layout.options
})
)
}
},
toggleFA2Layout() {
if (this.fa2Layout.isRunning()) {
this.fa2Running = false
this.fa2Layout.stop()
} else {
this.fa2Running = true
this.fa2Layout.start()
}
}
}
}
</script>
<style scoped>
.plotly_editor > div {
display: flex !important;
}
:deep(.customPickerContainer) {
float: right;
}
</style>

View File

@@ -1,79 +0,0 @@
<template>
<div ref="graphContainer" class="chart-container">
<div v-show="!dataSources" class="warning chart-warning">
There is no data to build a graph. Run your SQL query and make sure the
result is not empty.
</div>
<div
class="graph"
:style="{
height: !dataSources ? 'calc(100% - 40px)' : '100%',
'background-color': 'white'
}"
>
<GraphEditor :dataSources="dataSources" />
</div>
</div>
</template>
<script>
import 'react-chart-editor/lib/react-chart-editor.css'
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
import GraphEditor from './GraphEditor.vue'
export default {
name: 'Graph',
components: { GraphEditor },
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean
},
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
created() {
this.$emit('update:importToSvgEnabled', true)
},
mounted() {},
methods: {
getOptionsForSave() {},
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() {},
async prepareCopy(type = 'png') {}
}
}
</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

@@ -30,14 +30,6 @@
>
<pivot-icon />
</icon-button>
<icon-button
:active="mode === 'graph'"
tooltip="Switch to graph"
tooltipPosition="top-left"
@click="mode = 'graph'"
>
<graph-icon />
</icon-button>
<div class="side-tool-bar-divider" />
@@ -80,10 +72,10 @@
</side-tool-bar>
<loading-dialog
v-model="showLoadingDialog"
loadingMsg="Rendering the visualisation..."
successMsg="Image is ready"
actionBtnName="Copy"
name="prepareCopy"
title="Copy to clipboard"
:loading="preparingCopy"
@action="copyToClipboard"
@@ -93,20 +85,18 @@
</template>
<script>
import Chart from './Chart'
import Pivot from './Pivot'
import Graph from './Graph'
import Chart from './Chart/index.vue'
import Pivot from './Pivot/index.vue'
import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton'
import ChartIcon from '@/components/svg/chart'
import PivotIcon from '@/components/svg/pivot'
import GraphIcon from '@/components/svg/graph.vue'
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 loadingDialog from '@/components/LoadingDialog.vue'
import time from '@/lib/utils/time'
import events from '@/lib/utils/events'
@@ -115,12 +105,10 @@ export default {
components: {
Chart,
Pivot,
Graph,
SideToolBar,
IconButton,
ChartIcon,
PivotIcon,
GraphIcon,
ExportToSvgIcon,
PngIcon,
HtmlIcon,
@@ -141,7 +129,8 @@ export default {
loadingImage: false,
copyingImage: false,
preparingCopy: false,
dataToCopy: null
dataToCopy: null,
showLoadingDialog: false
}
},
computed: {
@@ -182,14 +171,13 @@ export default {
async prepareCopy() {
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.$modal.show('prepareCopy')
this.showLoadingDialog = true
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
@@ -202,14 +190,13 @@ export default {
)
}
},
async copyToClipboard() {
copyToClipboard() {
cIo.copyImage(this.dataToCopy)
this.$modal.hide('prepareCopy')
this.showLoadingDialog = false
this.exportSignal('clipboard')
},
cancelCopy() {
this.dataToCopy = null
this.$modal.hide('prepareCopy')
},
saveAsSvg() {

View File

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

View File

@@ -2,7 +2,7 @@ import { expect } from 'chai'
import sinon from 'sinon'
import { shallowMount } from '@vue/test-utils'
import { createStore } from 'vuex'
import App from '@/App'
import App from '@/App.vue'
import storedInquiries from '@/lib/storedInquiries'
import mutations from '@/store/mutations'
import { nextTick } from 'vue'
@@ -59,4 +59,37 @@ describe('App.vue', () => {
{ 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

@@ -413,7 +413,7 @@ describe('SQLite extensions', function () {
WHERE ip.id <= p.id
) AS path
FROM tmp, json_each(filename_array) AS p
WHERE p.id > 1 -- because the filenames start with the separator
WHERE p.key > 0 -- because the filenames start with the separator
`)
expect(actual.values).to.eql({
path: [

View File

@@ -71,7 +71,7 @@ describe('storedInquiries.js', () => {
query: 'SELECT * from foo',
viewType: 'chart',
viewOptions: [],
createdAt: new Date(2021, 0, 1),
createdAt: new Date(2021, 0, 1).toJSON(),
isPredefined: true
}
@@ -83,7 +83,8 @@ describe('storedInquiries.js', () => {
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('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')
})

View File

@@ -15,6 +15,7 @@ describe('tab.js', () => {
query: undefined,
viewOptions: undefined,
isPredefined: undefined,
updatedAt: undefined,
viewType: 'chart',
result: null,
isGettingResults: false,
@@ -42,7 +43,8 @@ describe('tab.js', () => {
viewType: 'pivot',
viewOptions: 'this is view options object',
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)
@@ -53,6 +55,7 @@ describe('tab.js', () => {
query: 'SELECT * from foo',
viewOptions: 'this is view options object',
isPredefined: undefined,
updatedAt: '2022-12-06T18:30:30',
viewType: 'pivot',
result: null,
isGettingResults: false,

View File

@@ -19,7 +19,8 @@ describe('actions', () => {
tempName: 'Untitled',
viewType: 'chart',
viewOptions: undefined,
isSaved: false
isSaved: false,
updatedAt: undefined
})
expect(state.untitledLastIndex).to.equal(1)
@@ -30,7 +31,8 @@ describe('actions', () => {
tempName: 'Untitled 1',
viewType: 'chart',
viewOptions: undefined,
isSaved: false
isSaved: false,
updatedAt: undefined
})
expect(state.untitledLastIndex).to.equal(2)
})
@@ -40,16 +42,16 @@ describe('actions', () => {
tabs: [],
untitledLastIndex: 0
}
const tab = {
const inquiry = {
id: 1,
name: 'test',
query: 'SELECT * from foo',
viewType: 'chart',
viewOptions: 'an object with view options',
isSaved: true
updatedAt: '2025-05-16T20:15:00Z'
}
await addTab({ state }, tab)
expect(state.tabs[0]).to.include(tab)
await addTab({ state }, inquiry)
expect(state.tabs[0]).to.include(inquiry)
expect(state.untitledLastIndex).to.equal(0)
})
@@ -166,21 +168,26 @@ describe('actions', () => {
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.query).to.equal(tab.query)
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])
})
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 = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: null,
name: 'foo',
updatedAt: '2025-05-16T20:15:00Z',
dataView: {
getOptionsForSave() {
return ['chart']
@@ -189,34 +196,34 @@ describe('actions', () => {
}
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]
}
const first = await saveInquiry(
{ state },
{
inquiryTab: tab,
newName: 'foo'
}
)
tab.name = 'foo'
tab.query = 'select * from foo'
await saveInquiry({ state }, { inquiryTab: tab })
tab.query = 'select * from bar'
await saveInquiry({ state }, { inquiryTab: tab, newName: '' })
const inquiries = state.inquiries
const second = inquiries[0]
const updatedTab = inquiries[0]
expect(inquiries).has.lengthOf(1)
expect(second.id).to.equal(first.id)
expect(second.name).to.equal(first.name)
expect(second.query).to.equal(tab.query)
expect(second.viewOptions).to.eql(['chart'])
expect(new Date(second.createdAt).getTime()).to.equal(
first.createdAt.getTime()
)
expect(updatedTab.id).to.equal(updatedTab.id)
expect(updatedTab.name).to.equal(updatedTab.name)
expect(updatedTab.query).to.equal(tab.query)
expect(updatedTab.viewOptions).to.eql(['chart'])
expect(updatedTab.createdAt).to.equal('2025-05-15T16:30:00Z')
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 nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = {
@@ -252,6 +259,95 @@ describe('actions', () => {
expect(inquiries[0].name).to.equal('foo')
expect(inquiries[0].query).to.equal(tab.query)
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)
})
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',
viewOptions: { here_are: 'chart settings' },
isSaved: false,
isPredefined: false
isPredefined: false,
updatedAt: '2025-05-15T15:30:00Z'
}
const newValues = {
@@ -43,6 +44,7 @@ describe('mutations', () => {
query: 'SELECT * from bar',
viewType: 'pivot',
viewOptions: { here_are: 'pivot settings' },
updatedAt: '2025-05-15T16:30:00Z',
isSaved: true
}
@@ -58,6 +60,7 @@ describe('mutations', () => {
query: 'SELECT * from bar',
viewType: 'pivot',
viewOptions: { here_are: 'pivot settings' },
updatedAt: '2025-05-15T16:30:00Z',
isSaved: true
})
})

View File

@@ -6,6 +6,8 @@ import MainMenu from '@/views/MainView/MainMenu'
import storedInquiries from '@/lib/storedInquiries'
import { nextTick } from 'vue'
import eventBus from '@/lib/eventBus'
import actions from '@/store/actions'
import mutations from '@/store/mutations'
let wrapper = null
@@ -26,7 +28,7 @@ describe('MainMenu.vue', () => {
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 = {
currentTab: { query: '', execute: sinon.stub() },
tabs: [{}],
@@ -45,6 +47,8 @@ describe('MainMenu.vue', () => {
})
expect(wrapper.find('#save-btn').exists()).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').isVisible()).to.equal(true)
wrapper.unmount()
@@ -65,7 +69,7 @@ describe('MainMenu.vue', () => {
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 = {
currentTab: null,
tabs: [],
@@ -83,6 +87,8 @@ describe('MainMenu.vue', () => {
})
expect(wrapper.find('#save-btn').exists()).to.equal(true)
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').isVisible()).to.equal(true)
})
@@ -111,10 +117,12 @@ describe('MainMenu.vue', () => {
})
const vm = wrapper.vm
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
await vm.$nextTick()
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 () => {
@@ -332,7 +340,7 @@ describe('MainMenu.vue', () => {
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 = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
@@ -353,36 +361,34 @@ describe('MainMenu.vue', () => {
plugins: [store]
}
})
sinon.stub(wrapper.vm, 'checkInquiryBeforeSave')
sinon.stub(wrapper.vm, 'onSave')
const ctrlS = new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
const metaS = new KeyboardEvent('keydown', { key: 's', metaKey: true })
// tab is unsaved and route is /workspace
document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledOnce).to.equal(true)
expect(wrapper.vm.onSave.calledOnce).to.equal(true)
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
store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
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
wrapper.vm.$route.path = '/inquiries'
store.state.tabs[0].isSaved = false
document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
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 = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
@@ -392,6 +398,81 @@ describe('MainMenu.vue', () => {
tabs: [tab],
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 = {
updateTab: sinon.stub()
}
@@ -401,7 +482,8 @@ describe('MainMenu.vue', () => {
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -446,7 +528,8 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo',
viewType: 'chart',
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)
})
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 () => {
const tab = {
id: 1,
@@ -463,7 +938,8 @@ describe('MainMenu.vue', () => {
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
isSaved: false,
updatedAt: '2025-05-15T15:30:00Z'
}
const state = {
currentTab: tab,
@@ -479,7 +955,8 @@ describe('MainMenu.vue', () => {
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -522,14 +999,15 @@ describe('MainMenu.vue', () => {
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 = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
isSaved: false,
updatedAt: undefined
}
const state = {
currentTab: tab,
@@ -542,10 +1020,11 @@ describe('MainMenu.vue', () => {
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo',
id: 1,
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -604,11 +1083,12 @@ describe('MainMenu.vue', () => {
tab,
newValues: {
name: 'foo',
id: 1,
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
}
})
)
@@ -650,7 +1130,8 @@ describe('MainMenu.vue', () => {
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -716,7 +1197,8 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
}
})
)
@@ -761,7 +1243,7 @@ describe('MainMenu.vue', () => {
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
chart: []
viewType: 'chart'
})
}
const store = createStore({ state, mutations, actions })
@@ -809,4 +1291,112 @@ describe('MainMenu.vue', () => {
// check that 'inquirySaved' event is not listened on eventBus
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 sinon from 'sinon'
import { nextTick } from 'vue'
import cIo from '@/lib/utils/clipboardIo'
describe('DataView.vue', () => {
const $store = { state: { isWorkspaceVisible: true } }
@@ -64,7 +65,7 @@ describe('DataView.vue', () => {
// Find chart and spy the method
const chart = wrapper.findComponent({ name: 'Chart' }).vm
sinon.spy(chart, 'saveAsSvg')
sinon.stub(chart, 'saveAsSvg')
// Export to svg
const svgBtn = wrapper.findComponent({ ref: 'svgExportBtn' })
@@ -77,7 +78,7 @@ describe('DataView.vue', () => {
// Find pivot and spy the method
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsSvg')
sinon.stub(pivot, 'saveAsSvg')
// Switch to Custom Chart renderer
pivot.pivotOptions.rendererName = 'Custom chart'
@@ -146,6 +147,7 @@ describe('DataView.vue', () => {
it('copy to clipboard more than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'write').resolves()
sinon.stub(cIo, 'copyImage')
const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, {
attachTo: document.body,
@@ -165,7 +167,7 @@ describe('DataView.vue', () => {
await copyBtn.trigger('click')
// 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(
'Copy to clipboard'
)
@@ -180,11 +182,10 @@ describe('DataView.vue', () => {
// Wait untill prepareCopy is finished
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
await nextTick()
await nextTick()
// 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...
expect(wrapper.find('.dialog-body').text()).to.equal('Image is ready')
@@ -196,12 +197,13 @@ describe('DataView.vue', () => {
// The dialog is not shown...
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()
})
it('copy to clipboard less than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'write').resolves()
sinon.stub(cIo, 'copyImage')
const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, {
attachTo: document.body,
@@ -229,7 +231,7 @@ describe('DataView.vue', () => {
await nextTick()
// The dialog is not shown...
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
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount()
@@ -270,7 +272,7 @@ describe('DataView.vue', () => {
// The dialog is not shown...
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
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount()

View File

@@ -78,7 +78,7 @@ describe('RunResult.vue', () => {
await nextTick()
// 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(
'Copy to clipboard'
)
@@ -91,7 +91,7 @@ describe('RunResult.vue', () => {
await nextTick()
// 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...
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
@@ -104,7 +104,7 @@ describe('RunResult.vue', () => {
// The dialog is not shown...
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()
})
@@ -143,7 +143,7 @@ describe('RunResult.vue', () => {
// The dialog is not shown...
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
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount()
@@ -188,7 +188,7 @@ describe('RunResult.vue', () => {
.trigger('click')
// The dialog is not shown...
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
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount()

View File

@@ -5,6 +5,9 @@ import mutations from '@/store/mutations'
import { createStore } from 'vuex'
import Tabs from '@/views/MainView/Workspace/Tabs'
import eventBus from '@/lib/eventBus'
import { nextTick } from 'vue'
import cIo from '@/lib/utils/clipboardIo'
import csv from '@/lib/csv'
describe('Tabs.vue', () => {
let clock
@@ -46,7 +49,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
chart: [],
viewType: 'chart',
isSaved: true
},
{
@@ -54,7 +57,7 @@ describe('Tabs.vue', () => {
name: null,
tempName: 'Untitled',
query: '',
chart: [],
viewType: 'chart',
isSaved: false
}
],
@@ -97,7 +100,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
chart: [],
viewType: 'chart',
isSaved: true
},
{
@@ -105,7 +108,7 @@ describe('Tabs.vue', () => {
name: null,
tempName: 'Untitled',
query: '',
chart: [],
viewType: 'chart',
isSaved: false
}
],
@@ -436,7 +439,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
chart: [],
viewType: 'chart',
isSaved: true
},
{
@@ -444,7 +447,7 @@ describe('Tabs.vue', () => {
name: null,
tempName: 'Untitled',
query: '',
chart: [],
viewType: 'chart',
isSaved: false
}
],
@@ -477,7 +480,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
chart: [],
viewType: 'chart',
isSaved: true
}
],
@@ -501,4 +504,216 @@ describe('Tabs.vue', () => {
expect(event.preventDefault.calledOnce).to.equal(false)
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()
})
})