1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2026-03-24 23:16:18 +08:00

Compare commits

..

4 Commits

Author SHA1 Message Date
lana-k
4232f15c04 autostart, reset and fixes 2025-08-17 21:21:21 +02:00
lana-k
9d562d11b8 export and background 2025-06-09 21:08:51 +02:00
lana-k
54cdbbc8b9 wip 2025-06-06 21:24:04 +02:00
lana-k
1601514cca wip 2025-06-01 19:16:26 +02:00
52 changed files with 22285 additions and 7755 deletions

View File

@@ -11,7 +11,7 @@ on:
jobs:
test:
name: Run tests
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Use Node.js
@@ -21,9 +21,8 @@ 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-esr
sudo apt-get install -y chromium-browser firefox
- 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-bullseye
FROM node:12.22-buster
RUN set -ex; \
apt update; \

View File

@@ -9,7 +9,7 @@ of SQLite databases, CSV, JSON or NDJSON files.
With sqliteviz you can:
- run SQL queries against a SQLite database and create [Plotly][11] charts, graphs and pivot tables based on the result sets
- run SQL queries against a SQLite database and create [Plotly][11] charts and pivot tables based on the result sets
- import a CSV/JSON/NDJSON file into a SQLite database and visualize imported data
- export result set to CSV file
- manage inquiries and run them against different databases
@@ -33,9 +33,7 @@ It's a kind of middleground between [Plotly Falcon][1] and [Redash][2].
## Components
It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4]
and [Vue-Codemirror][8] in [Vue.js][5]. CSV parsing is performed with [Papa Parse][9].
Graphs are visualized with [Sigma.js][13] and [Graphology][14].
It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4] and [Vue-Codemirror][8] in [Vue.js][5]. CSV parsing is performed with [Papa Parse][9].
[1]: https://github.com/plotly/falcon
[2]: https://github.com/getredash/redash
@@ -49,5 +47,3 @@ Graphs are visualized with [Sigma.js][13] and [Graphology][14].
[10]: https://github.com/lana-k/sqliteviz/wiki/Predefined-queries
[11]: https://github.com/plotly/plotly.js
[12]: https://github.com/nicolaskruchten/pivottable
[13]: https://www.sigmajs.org/
[14]: https://graphology.github.io/

View File

@@ -10,7 +10,7 @@ from pathlib import Path
from urllib import request
amalgamation_url = 'https://sqlite.org/2025/sqlite-amalgamation-3500300.zip'
amalgamation_url = 'https://sqlite.org/2023/sqlite-amalgamation-3410000.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/e212edb2?at=series.c', 'sqlite3_series_init'),
('https://sqlite.org/src/raw/5559daf1?at=closure.c', 'sqlite3_closure_init'),
('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/5bb2264c?at=uuid.c', 'sqlite3_uuid_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'),
('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'),
# Third-party extension
# =====================
('https://github.com/jakethaw/pivot_vtab/raw/e7705f34/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
('https://github.com/jakethaw/pivot_vtab/raw/9323ef93/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.

23776
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,13 +1,16 @@
<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
@@ -23,16 +26,53 @@ export default {
},
created() {
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
addEventListener('storage', event => {
if (event.key === storedInquiries.myInquiriesKey) {
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
}
})
}
}
</script>
<style>
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Regular.woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBold.woff2');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Bold.woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Italic.woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBoldItalic.woff2');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-BoldItalic.woff2');
font-weight: 700;
font-style: italic;
}
#app,
.dialog,
input,

View File

@@ -4,10 +4,3 @@
font-size: 13px;
padding: 0 24px;
}
.data-view-warning {
height: 40px;
line-height: 40px;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
}

View File

@@ -1,45 +0,0 @@
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Regular.woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBold.woff2');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Bold.woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Italic.woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBoldItalic.woff2');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-BoldItalic.woff2');
font-weight: 700;
font-style: italic;
}
a {
color: var(--color-accent-shade);
}

View File

@@ -1,15 +1,5 @@
<template>
<Field label="Scaling ratio" fieldContainerClassName="test_fa2_scaling">
<NumericInput
:value="modelValue.scalingRatio"
@update="update('scalingRatio', $event)"
/>
</Field>
<Field
label="Prevent overlapping"
fieldContainerClassName="test_fa2_adjustSizes"
>
<Field label="Adjust sizes">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.adjustSizes"
@@ -17,10 +7,7 @@
/>
</Field>
<Field
label="Barnes-Hut optimize"
fieldContainerClassName="test_fa2_barnes_hut"
>
<Field label="Barnes-Hut optimize">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.barnesHutOptimize"
@@ -28,21 +15,21 @@
/>
</Field>
<Field
v-show="modelValue.barnesHutOptimize"
label="Barnes-Hut Theta"
fieldContainerClassName="test_fa2_barnes_theta"
>
<Field v-show="modelValue.barnesHutOptimize" label="Barnes-Hut Theta">
<NumericInput
:value="modelValue.barnesHutTheta"
@update="update('barnesHutTheta', $event)"
/>
</Field>
<Field
label="Strong gravity mode"
fieldContainerClassName="test_fa2_strong_gravity"
>
<Field label="Gravity">
<NumericInput
:value="modelValue.gravity"
@update="update('gravity', $event)"
/>
</Field>
<Field label="Strong gravity mode">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.strongGravityMode"
@@ -50,10 +37,7 @@
/>
</Field>
<Field
label="Noack's LinLog model"
fieldContainerClassName="test_fa2_lin_log"
>
<Field label="Noack's LinLog model">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.linLogMode"
@@ -61,10 +45,7 @@
/>
</Field>
<Field
label="Outbound attraction distribution"
fieldContainerClassName="test_fa2_outbound_attraction"
>
<Field label="Out bound attraction distribution">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.outboundAttractionDistribution"
@@ -72,7 +53,7 @@
/>
</Field>
<Field label="Slow down" fieldContainerClassName="test_fa2_slow_down">
<Field label="Slow down">
<NumericInput
:value="modelValue.slowDown"
:min="0"
@@ -84,15 +65,10 @@
<Dropdown
:options="keyOptions"
:value="modelValue.weightSource"
className="test_fa2_weight_source"
@change="update('weightSource', $event)"
/>
</Field>
<Field
v-show="modelValue.weightSource"
label="Edge weight influence"
fieldContainerClassName="test_fa2_weight_influence"
>
<Field v-show="modelValue.weightSource" label="Edge weight influence">
<NumericInput
:value="modelValue.edgeWeightInfluence"
@update="update('edgeWeightInfluence', $event)"

View File

@@ -1,21 +1,18 @@
<template>
<Field label="Color" fieldContainerClassName="test_edge_color">
<Field label="Color">
<RadioBlocks
:options="edgeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field
v-if="modelValue.type === 'constant'"
fieldContainerClassName="test_edge_color_value"
>
<Field v-if="modelValue.type === 'constant'">
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field fieldContainerClassName="test_edge_color_value">
<Field>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
@@ -24,7 +21,7 @@
/>
</Field>
<Field fieldContainerClassName="test_edge_color_mapping_mode">
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="modelValue.sourceUsage"
@@ -42,11 +39,7 @@
</template>
</Field>
<Field
v-if="modelValue.type !== 'constant' && modelValue.sourceUsage === 'map_to'"
label="Color as"
fieldContainerClassName="test_edge_color_as"
>
<Field v-if="modelValue.type !== 'constant'" label="Color as">
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@@ -54,11 +47,7 @@
/>
</Field>
<Field
v-if="modelValue.type !== 'constant' && modelValue.sourceUsage === 'map_to'"
label="Colorscale direction"
fieldContainerClassName="test_edge_color_colorscale_direction"
>
<Field v-if="modelValue.type !== 'constant'" label="Colorscale direction">
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="modelValue.colorscaleDirection"

View File

@@ -1,12 +1,12 @@
<template>
<Field label="Size" fieldContainerClassName="test_edge_size">
<Field label="Size">
<RadioBlocks
:options="edgeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field fieldContainerClassName="test_edge_size_value">
<Field>
<NumericInput
v-if="modelValue.type === 'constant'"
:value="modelValue.value"
@@ -23,14 +23,14 @@
</Field>
<template v-if="modelValue.type !== 'constant'">
<Field label="Size scale" fieldContainerClassName="test_edge_size_scale">
<Field label="Size scale">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Minimum size" fieldContainerClassName="test_edge_size_min">
<Field label="Minimum size">
<NumericInput
:value="modelValue.min"
@update="updateSettings('min', $event)"

View File

@@ -1,8 +1,5 @@
<template>
<Field
label="Initial iterations"
fieldContainerClassName="test_fa2_iteration_amount"
>
<Field label="Initial iterations">
<NumericInput
:value="modelValue.initialIterationsAmount"
:min="1"
@@ -10,10 +7,10 @@
/>
</Field>
<Field label="Gravity" fieldContainerClassName="test_fa2_gravity">
<Field label="Scaling ratio">
<NumericInput
:value="modelValue.gravity"
@update="update('gravity', $event)"
:value="modelValue.scalingRatio"
@update="update('scalingRatio', $event)"
/>
</Field>
</template>

View File

@@ -1,21 +1,18 @@
<template>
<Field label="Color" fieldContainerClassName="test_node_color">
<Field label="Color">
<RadioBlocks
:options="nodeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field
v-if="modelValue.type === 'constant'"
fieldContainerClassName="test_node_color_value"
>
<Field v-if="modelValue.type === 'constant'">
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field fieldContainerClassName="test_node_color_value">
<Field>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
@@ -26,15 +23,11 @@
v-if="modelValue.type === 'calculated'"
:options="nodeCalculatedColorMethodOptions"
:value="modelValue.method"
:clearable="false"
@change="updateSettings('method', $event)"
/>
</Field>
<Field
v-if="modelValue.type === 'variable'"
fieldContainerClassName="test_node_color_mapping_mode"
>
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="modelValue.sourceUsage"
@@ -42,12 +35,7 @@
/>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' ||
modelValue.type === 'calculated'
"
>
<Field v-if="modelValue.sourceUsage === 'map_to'">
<ColorscalePicker
:selected="modelValue.colorscale"
className="colorscale-picker"
@@ -57,13 +45,7 @@
</template>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' || modelValue.type === 'calculated'
"
label="Color as"
fieldContainerClassName="test_node_color_as"
>
<Field v-if="modelValue.type !== 'constant'" label="Color as">
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@@ -71,13 +53,7 @@
/>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' || modelValue.type === 'calculated'
"
label="Colorscale direction"
fieldContainerClassName="test_node_color_colorscale_direction"
>
<Field v-if="modelValue.type !== 'constant'" label="Colorscale direction">
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="modelValue.colorscaleDirection"
@@ -144,6 +120,7 @@ export default {
},
calculated: {
method: 'degree',
sourceUsage: 'map_to',
colorscale: null,
mode: 'continious',
colorscaleDirection: 'normal'

View File

@@ -1,17 +1,16 @@
<template>
<Field label="Size" fieldContainerClassName="test_node_size">
<Field label="Size">
<RadioBlocks
:options="nodeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field fieldContainerClassName="test_node_size_value">
<Field>
<NumericInput
v-if="modelValue.type === 'constant'"
:value="modelValue.value"
:min="1"
class="test_node_size_value"
@update="updateSettings('value', $event)"
/>
<Dropdown
@@ -24,21 +23,20 @@
v-if="modelValue.type === 'calculated'"
:options="nodeCalculatedSizeMethodOptions"
:value="modelValue.method"
:clearable="false"
@change="updateSettings('method', $event)"
/>
</Field>
</Field>
<template v-if="modelValue.type !== 'constant'">
<Field label="Size scale" fieldContainerClassName="test_node_size_scale">
<Field label="Size scale">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Size mode" fieldContainerClassName="test_node_size_mode">
<Field label="Size mode">
<RadioBlocks
:options="nodeSizeModeOptions"
:activeOption="modelValue.mode"
@@ -46,7 +44,7 @@
/>
</Field>
<Field label="Minimum size" fieldContainerClassName="test_node_size_min">
<Field label="Minimum size">
<NumericInput
:value="modelValue.min"
@update="updateSettings('min', $event)"

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -5,39 +5,8 @@ const TYPE_NODE = 0
const TYPE_EDGE = 1
const DEFAULT_SCALE = COLOR_PICKER_CONSTANTS.DEFAULT_SCALE
export function dataSourceIsValid(dataSources) {
const docColumn = Object.keys(dataSources)[0]
if (!docColumn) {
return false
}
try {
const records = dataSources[docColumn].slice(0, 10)
records.forEach(record => {
const parsedRec = JSON.parse(record)
if (Object.keys(parsedRec).length < 2) {
throw new Error('The records must have at least 2 keys')
}
})
const firstRecord = JSON.parse(records[0])
if (
!Object.keys(firstRecord).some(key => {
return records
.map(record => JSON.parse(record)[key])
.every(value => value === 0 || value === 1)
})
) {
throw new Error(
'There must be a common key used as object type: 0 - node, 1 - edge'
)
}
return true
} catch (err) {
return false
}
}
export function buildNodes(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0]
const docColumn = Object.keys(dataSources)[0] || 'doc'
const { objectType, nodeId } = options.structure
if (objectType && nodeId) {
@@ -45,17 +14,16 @@ export function buildNodes(graph, dataSources, options) {
.map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_NODE)
nodes.forEach(node => {
if (node[nodeId]) {
graph.addNode(node[nodeId], {
data: node
})
}
graph.addNode(node[nodeId], {
data: node,
labelColor: options.style.nodes.label.color
})
})
}
}
export function buildEdges(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0]
const docColumn = Object.keys(dataSources)[0] || 'doc'
const { objectType, edgeSource, edgeTarget } = options.structure
if (objectType && edgeSource && edgeTarget) {
@@ -68,7 +36,8 @@ export function buildEdges(graph, dataSources, options) {
const target = edge[edgeTarget]
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.addEdge(source, target, {
data: edge
data: edge,
labelColor: options.style.edges.label.color
})
}
})
@@ -141,23 +110,10 @@ function getUpdateSizeMethod(graph, sizeSettings) {
if (type === 'constant') {
return attributes => (attributes.size = value)
} else if (type === 'variable') {
return attributes => {
attributes.size = getVariabledSize(
mode,
attributes.data[source],
scale,
min
)
}
return getVariabledSizeMethod(mode, source, scale, min)
} else {
return (attributes, nodeId) => {
attributes.size = getVariabledSize(
mode,
graph[method](nodeId),
scale,
min
)
}
return (attributes, nodeId) =>
(attributes.size = Math.max(graph[method](nodeId) * scale, min))
}
}
@@ -228,13 +184,22 @@ function getUpdateEdgeColorMethod(graph, colorSettings) {
}
}
function getVariabledSize(mode, value, scale, min) {
function getVariabledSizeMethod(mode, source, scale, min) {
if (mode === 'diameter') {
return Math.max((value / 2) * scale, min / 2)
return attributes =>
(attributes.size = Math.max(
(attributes.data[source] / 2) * scale,
min / 2
))
} else if (mode === 'area') {
return Math.max(Math.sqrt((value / 2) * scale), min / 2)
return attributes =>
(attributes.size = Math.max(
Math.sqrt((attributes.data[source] / 2) * scale),
min / 2
))
} else {
return Math.max(value * scale, min)
return attributes =>
(attributes.size = Math.max(attributes.data[source] * scale, min))
}
}
@@ -259,6 +224,7 @@ function getColorMethod(
colorscale[index % colorscale.length]
])
)
return (attributes, nodeId) => {
const category = sourceGetter(nodeId, attributes)
attributes.color = colorMap[category]
@@ -274,7 +240,6 @@ function getColorMethod(
const value = sourceGetter(nodeId, attributes)
const normalizedValue = (value - min) / (max - min)
if (isNaN(normalizedValue)) {
attributes.color = '#000000'
return
}
const exactMatch = normalizedColorscale.find(
@@ -286,9 +251,9 @@ function getColorMethod(
}
const rightColorIndex = normalizedColorscale.findIndex(
([value]) => value > normalizedValue
([value]) => value >= normalizedValue
)
const leftColorIndex = rightColorIndex - 1
const leftColorIndex = (rightColorIndex || 1) - 1
const right = normalizedColorscale[rightColorIndex]
const left = normalizedColorscale[leftColorIndex]
const interpolationFactor =
@@ -325,3 +290,18 @@ function getEdgeValueScale(graph, sourceGetter) {
}, 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 default {
getOptionsFromDataSources
}

View File

@@ -4,13 +4,11 @@ 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(myInquiriesKey))
let myInquiries = JSON.parse(localStorage.getItem('myInquiries'))
if (!myInquiries) {
const oldInquiries = localStorage.getItem('myQueries')
if (oldInquiries) {
@@ -28,8 +26,7 @@ export default {
const newInquiry = JSON.parse(JSON.stringify(baseInquiry))
newInquiry.name = newInquiry.name + ' Copy'
newInquiry.id = nanoid()
newInquiry.createdAt = new Date().toJSON()
newInquiry.updatedAt = new Date().toJSON()
newInquiry.createdAt = new Date()
delete newInquiry.isPredefined
return newInquiry
@@ -41,7 +38,7 @@ export default {
updateStorage(inquiries) {
localStorage.setItem(
myInquiriesKey,
'myInquiries',
JSON.stringify({ version: this.version, inquiries })
)
},

View File

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

View File

@@ -5,7 +5,6 @@ import store from '@/store'
import { createVfm, VueFinalModal, useVfm } from 'vue-final-modal'
import '@/assets/styles/variables.css'
import '@/assets/styles/typography.css'
import '@/assets/styles/buttons.css'
import '@/assets/styles/tables.css'
import '@/assets/styles/dialogs.css'

View File

@@ -7,7 +7,7 @@ import LoadView from '@/views/LoadView'
import store from '@/store'
import database from '@/lib/database'
export const routes = [
const routes = [
{
path: '/',
name: 'Welcome',

View File

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

View File

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

View File

@@ -10,22 +10,14 @@
</div>
<div id="nav-buttons">
<button
v-show="currentInquiryTab && $route.path === '/workspace'"
v-show="currentInquiry && $route.path === '/workspace'"
id="save-btn"
class="primary"
:disabled="isSaved"
@click="onSave(false)"
@click="checkInquiryBeforeSave"
>
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>
@@ -53,34 +45,7 @@
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="cancelSave">Cancel</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>
<button class="primary" @click="saveInquiry">Save</button>
</div>
</modal>
</nav>
@@ -108,28 +73,25 @@ export default {
}
},
computed: {
inquiries() {
return this.$store.state.inquiries
},
currentInquiryTab() {
currentInquiry() {
return this.$store.state.currentTab
},
isSaved() {
return this.currentInquiryTab && this.currentInquiryTab.isSaved
return this.currentInquiry && this.currentInquiry.isSaved
},
isPredefined() {
return this.currentInquiryTab && this.currentInquiryTab.isPredefined
return this.currentInquiry && this.currentInquiry.isPredefined
},
runDisabled() {
return (
this.currentInquiryTab &&
(!this.$store.state.db || !this.currentInquiryTab.query)
this.currentInquiry &&
(!this.$store.state.db || !this.currentInquiry.query)
)
}
},
created() {
eventBus.$on('createNewInquiry', this.createNewInquiry)
eventBus.$on('saveInquiry', this.onSave)
eventBus.$on('saveInquiry', this.checkInquiryBeforeSave)
document.addEventListener('keydown', this._keyListener)
},
beforeUnmount() {
@@ -147,84 +109,63 @@ export default {
events.send('inquiry.create', null, { auto: false })
},
cancelSave() {
this.errorMsg = null
this.name = ''
this.$modal.hide('save')
this.$modal.hide('inquiry-conflict')
eventBus.$off('inquirySaved')
},
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.$modal.hide('inquiry-conflict')
checkInquiryBeforeSave() {
this.errorMsg = null
this.name = ''
this.$modal.show('save')
if (storedInquiries.isTabNeedName(this.currentInquiry)) {
this.$modal.show('save')
} else {
this.saveInquiry()
}
},
validateSaveFormAndSaveInquiry() {
if (!this.name) {
async saveInquiry() {
const isNeedName = storedInquiries.isTabNeedName(this.currentInquiry)
if (isNeedName && !this.name) {
this.errorMsg = "Inquiry name can't be empty"
return
}
this.saveInquiry()
},
async saveInquiry() {
const eventName =
this.currentInquiryTab.name && this.name
? 'inquiry.saveAs'
: 'inquiry.save'
const dataSet = this.currentInquiry.result
const tabView = this.currentInquiry.view
// Save inquiry
const value = await this.$store.dispatch('saveInquiry', {
inquiryTab: this.currentInquiryTab,
inquiryTab: this.currentInquiry,
newName: this.name
})
// Update tab in store
this.$store.commit('updateTab', {
tab: this.currentInquiryTab,
tab: this.currentInquiry,
newValues: {
name: value.name,
id: value.id,
query: value.query,
viewType: value.viewType,
viewOptions: value.viewOptions,
isSaved: true,
updatedAt: value.updatedAt
isSaved: true
}
})
// Hide dialogs
// Restore data:
// e.g. if we save predefined inquiry the tab will be created again
// (because of new id) and
// it will be without sql result and has default view - table.
// That's why we need to restore data and view
this.$nextTick(() => {
this.currentInquiry.result = dataSet
this.currentInquiry.view = tabView
})
// Hide dialog
this.$modal.hide('save')
this.$modal.hide('inquiry-conflict')
this.errorMsg = null
this.name = ''
// Signal about saving
eventBus.$emit('inquirySaved')
events.send(eventName)
events.send('inquiry.save')
},
_keyListener(e) {
if (this.$route.path === '/workspace') {
@@ -232,25 +173,19 @@ export default {
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!this.runDisabled) {
this.currentInquiryTab.execute()
this.currentInquiry.execute()
}
return
}
// Save inquiry Ctrl+S
if (e.key === 's' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!this.isSaved) {
this.onSave()
this.checkInquiryBeforeSave()
}
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

@@ -1,6 +1,6 @@
<template>
<div ref="chartContainer" class="chart-container">
<div v-show="!dataSources" class="warning data-view-warning">
<div v-show="!dataSources" class="warning chart-warning">
There is no data to build a chart. Run your SQL query and make sure the
result is not empty.
</div>
@@ -20,7 +20,6 @@
:useResizeHandler="useResizeHandler"
:debug="true"
:advancedTraceTypeSelector="true"
:hideControls="!showViewSettings"
@update="update"
@render="onRender"
/>
@@ -48,8 +47,7 @@ export default {
initOptions: Object,
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
forPivot: Boolean,
showViewSettings: Boolean
forPivot: Boolean
},
emits: [
'update:exportToSvgEnabled',
@@ -87,9 +85,6 @@ export default {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
},
showViewSettings() {
this.handleResize()
}
},
created() {
@@ -120,8 +115,8 @@ export default {
this.resizeObserver.observe(this.$refs.chartContainer)
if (this.dataSources) {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
this.handleResize()
},
activated() {
this.useResizeHandler = true
@@ -134,10 +129,6 @@ export default {
},
methods: {
async handleResize() {
// Call updatePlotly twice because there is a small gap (for scrolling?)
// on right and bottom of the plot.
// After the second call it's good.
this.updatePlotly()
this.updatePlotly()
},
onRender() {
@@ -176,8 +167,11 @@ export default {
'text/html'
)
},
prepareCopy(type = 'png') {
return chartHelper.getImageDataUrl(this.$refs.plotlyEditor.$el, type)
async prepareCopy(type = 'png') {
return await chartHelper.getImageDataUrl(
this.$refs.plotlyEditor.$el,
type
)
}
}
}
@@ -188,6 +182,13 @@ export default {
height: 100%;
}
.chart-warning {
height: 40px;
line-height: 40px;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
}
.chart {
min-height: 242px;
}

View File

@@ -1,61 +1,41 @@
<template>
<div :class="['plotly_editor', { with_controls: showViewSettings }]">
<GraphEditorControls v-show="showViewSettings">
<div class="plotly_editor">
<GraphEditorControls>
<PanelMenuWrapper>
<Panel group="Structure" name="Graph">
<Fold name="Graph">
<Field>
Map your result set records to node and edge properties required
to build a graph. Learn more about result set requirements in the
<a href="https://sqliteviz.com/docs/graph/" target="_blank">
documentation</a
>.
</Field>
<Field label="Object type" ref="objectTypeField">
<Field>Choose keys explanation...</Field>
<Field label="Object type">
<Dropdown
:options="keysOptions"
:value="settings.structure.objectType"
className="test_object_type_select"
@change="updateStructure('objectType', $event)"
/>
<Field>
A field indicating if the record is node (value&nbsp;0) or edge
(value&nbsp;1).
</Field>
<Field>0 - node; 1 - edge</Field>
</Field>
<Field label="Node Id">
<Dropdown
:options="keysOptions"
:value="settings.structure.nodeId"
className="test_node_id_select"
@change="updateStructure('nodeId', $event)"
/>
<Field> A field keeping unique node identifier. </Field>
</Field>
<Field label="Edge source">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeSource"
className="test_edge_source_select"
@change="updateStructure('edgeSource', $event)"
/>
<Field>
A field keeping a node identifier where the edge starts.
</Field>
</Field>
<Field label="Edge target">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeTarget"
className="test_edge_target_select"
@change="updateStructure('edgeTarget', $event)"
/>
<Field>
A field keeping a node identifier where the edge ends.
</Field>
</Field>
</Fold>
</Panel>
@@ -75,7 +55,6 @@
<Dropdown
:options="keysOptions"
:value="settings.style.nodes.label.source"
className="test_label_select"
@change="updateNodes('label.source', $event)"
/>
</Field>
@@ -102,10 +81,7 @@
<Panel group="Style" name="Edges">
<Fold name="Edges">
<Field
label="Direction"
fieldContainerClassName="test_edge_direction"
>
<Field label="Direction">
<RadioBlocks
:options="visibilityOptions"
:activeOption="settings.style.edges.showDirection"
@@ -117,7 +93,6 @@
<Dropdown
:options="keysOptions"
:value="settings.style.edges.label.source"
className="test_edge_label_select"
@change="updateEdges('label.source', $event)"
/>
</Field>
@@ -148,8 +123,6 @@
<Dropdown
:options="layoutOptions"
:value="settings.layout.type"
:clearable="false"
className="test_layout_algorithm_select"
@change="updateLayout($event)"
/>
</Field>
@@ -170,18 +143,10 @@
/>
</Fold>
<div class="force-atlas-buttons">
<Button
variant="secondary"
@click="resetFA2LayoutSettings"
class="test_fa2_reset"
>
<Button variant="secondary" @click="resetFA2LayoutSettings">
Reset
</Button>
<Button
variant="primary"
@click="toggleFA2Layout"
class="test_fa2_toggle"
>
<Button variant="primary" @click="toggleFA2Layout">
<template #node:icon>
<div
:style="{
@@ -199,10 +164,8 @@
</Panel>
</PanelMenuWrapper>
</GraphEditorControls>
<div
ref="graph"
class="test_graph_output"
:style="{
height: '100%',
width: '100%',
@@ -228,7 +191,7 @@ import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSetting
import AdvancedForceAtlasLayoutSettings from '@/components/Graph/AdvancedForceAtlasLayoutSettings.vue'
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue'
import FA2Layout from 'graphology-layout-forceatlas2/worker'
import * as forceAtlas2 from 'graphology-layout-forceatlas2'
import forceAtlas2 from 'graphology-layout-forceatlas2'
import RunIcon from '@/components/svg/run.vue'
import StopIcon from '@/components/svg/stop.vue'
import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image'
@@ -246,7 +209,6 @@ import NodeColorSettings from '@/components/Graph/NodeColorSettings.vue'
import NodeSizeSettings from '@/components/Graph/NodeSizeSettings.vue'
import EdgeSizeSettings from '@/components/Graph/EdgeSizeSettings.vue'
import EdgeColorSettings from '@/components/Graph/EdgeColorSettings.vue'
import events from '@/lib/utils/events'
export default {
components: {
@@ -273,8 +235,7 @@ export default {
inject: ['tabLayout'],
props: {
dataSources: Object,
initOptions: Object,
showViewSettings: Boolean
initOptions: Object
},
emits: ['update'],
data() {
@@ -314,7 +275,7 @@ export default {
nodes: {
size: {
type: 'constant',
value: 10
value: 4
},
color: {
type: 'constant',
@@ -358,10 +319,11 @@ export default {
if (!this.dataSources) {
return []
}
const firstColumnName = Object.keys(this.dataSources)[0]
try {
return (
this.dataSources[firstColumnName].map(json => JSON.parse(json)) || []
this.dataSources[Object.keys(this.dataSources)[0] || 'doc'].map(
json => JSON.parse(json)
) || []
)
} catch {
return []
@@ -397,14 +359,6 @@ export default {
this.buildGraph()
}
},
'settings.layout.type': {
immediate: true,
handler() {
events.send('viz_graph.render', null, {
layout: this.settings.layout.type
})
}
},
tabLayout: {
deep: true,
handler() {
@@ -436,7 +390,9 @@ export default {
this.updateLayout(this.settings.layout.type)
this.renderer = new Sigma(this.graph, this.$refs.graph, {
renderEdgeLabels: true,
allowInvalidContainer: true
allowInvalidContainer: true,
labelColor: { attribute: 'labelColor', color: '#444444' },
edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' }
})
if (this.settings.layout.type === 'forceAtlas2') {
this.autoRunFA2Layout()
@@ -508,7 +464,7 @@ export default {
if (layoutType === 'random') {
random.assign(this.graph, {
rng: seedrandom(this.settings.layout.options.seedValue)
rng: seedrandom(this.settings.layout.options.seedValue || 1)
})
return
}
@@ -541,7 +497,7 @@ export default {
this.settings.layout.options.hierarchyAttributes?.map(
(_, index) => 'hierarchyAttribute' + index
) || [],
rng: seedrandom(this.settings.layout.options.seedValue)
rng: seedrandom(this.settings.layout.options.seedValue || 1)
})
return
}
@@ -591,6 +547,10 @@ export default {
}
},
autoRunFA2Layout() {
if (this.fa2Layout.isRunning()) {
this.stopFA2Layout()
}
let iteration = 1
this.checkIteration = () => {
if (
@@ -605,7 +565,7 @@ export default {
this.fa2Layout.start()
},
setRecommendedFA2Settings() {
const sensibleSettings = forceAtlas2.default.inferSettings(this.graph)
const sensibleSettings = forceAtlas2.inferSettings(this.graph)
this.settings.layout.options = {
initialIterationsAmount: 50,
adjustSizes: false,
@@ -651,7 +611,7 @@ export default {
</script>
<style scoped>
.plotly_editor.with_controls > div {
.plotly_editor > div {
display: flex !important;
}

View File

@@ -1,30 +1,19 @@
<template>
<div ref="graphContainer" class="graph-container">
<div v-show="!dataSources" class="warning data-view-warning no-data">
<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
v-show="!dataSourceIsValid"
class="warning data-view-warning invalid-data"
>
Result set is invalid for graph visualisation. Learn more in
<a href="https://sqliteviz.com/docs/graph/" target="_blank">
documentation</a
>.
</div>
<div
class="graph"
:style="{
height:
!dataSources || !dataSourceIsValid ? 'calc(100% - 40px)' : '100%'
height: !dataSources ? 'calc(100% - 40px)' : '100%'
}"
>
<GraphEditor
ref="graphEditor"
:dataSources="dataSources"
:initOptions="initOptions"
:showViewSettings="showViewSettings"
@update="$emit('update')"
/>
</div>
@@ -33,8 +22,8 @@
<script>
import 'react-chart-editor/lib/react-chart-editor.css'
import GraphEditor from '@/components/Graph/GraphEditor.vue'
import { dataSourceIsValid } from '@/lib/graphHelper'
import events from '@/lib/utils/events'
import GraphEditor from './GraphEditor.vue'
export default {
name: 'Graph',
@@ -44,14 +33,11 @@ export default {
initOptions: Object,
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
exportToHtmlEnabled: Boolean,
showViewSettings: Boolean
exportToHtmlEnabled: Boolean
},
emits: [
'update:exportToSvgEnabled',
'update:exportToHtmlEnabled',
'update:exportToPngEnabled',
'update:exportToClipboardEnabled',
'update',
'loadingImageCompleted'
],
@@ -60,11 +46,10 @@ export default {
resizeObserver: null
}
},
created() {
this.$emit('update:exportToSvgEnabled', false)
this.$emit('update:exportToHtmlEnabled', false)
this.$emit('update:exportToPngEnabled', !!this.dataSources)
this.$emit('update:exportToClipboardEnabled', !!this.dataSources)
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
@@ -73,21 +58,6 @@ export default {
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.graphContainer)
},
watch: {
async showViewSettings() {
await this.$nextTick()
this.handleResize()
},
dataSources() {
this.$emit('update:exportToPngEnabled', !!this.dataSources)
this.$emit('update:exportToClipboardEnabled', !!this.dataSources)
}
},
computed: {
dataSourceIsValid() {
return !this.dataSources || dataSourceIsValid(this.dataSources)
}
},
methods: {
getOptionsForSave() {
return this.$refs.graphEditor.settings
@@ -96,14 +66,14 @@ export default {
await this.$refs.graphEditor.saveAsPng()
this.$emit('loadingImageCompleted')
},
prepareCopy() {
return this.$refs.graphEditor.prepareCopy()
async prepareCopy() {
return await this.$refs.graphEditor.prepareCopy()
},
async handleResize() {
const renderer = this.$refs.graphEditor.renderer
if (renderer) {
renderer.refresh()
renderer.getCamera().animatedReset({ duration: 600 })
renderer.getCamera().setState({ x: 0.5, y: 0.5 })
}
}
}
@@ -111,11 +81,18 @@ export default {
</script>
<style scoped>
.graph-container {
.chart-container {
height: 100%;
}
.graph {
.chart-warning {
height: 40px;
line-height: 40px;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
}
.chart {
min-height: 242px;
}

View File

@@ -1,132 +1,137 @@
<template>
<div class="pivot-ui">
<div class="row">
<label>Columns</label>
<multiselect
v-model="cols"
class="sqliteviz-select cols"
:options="colsToSelect"
:disabled="colsToSelect.length === 0"
:multiple="true"
:hideSelected="true"
:closeOnSelect="true"
:showLabels="false"
:max="colsToSelect.length"
openDirection="bottom"
placeholder=""
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<div :class="{ collapsed }">
<div class="row">
<label>Columns</label>
<multiselect
v-model="cols"
class="sqliteviz-select cols"
:options="colsToSelect"
:disabled="colsToSelect.length === 0"
:multiple="true"
:hideSelected="true"
:closeOnSelect="true"
:showLabels="false"
:max="colsToSelect.length"
openDirection="bottom"
placeholder=""
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<template #placeholder>Choose columns</template>
<template #placeholder>Choose columns</template>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn v-model="colOrder" class="sort-btn" direction="col" />
</div>
<div class="row">
<label>Rows</label>
<multiselect
v-model="rows"
class="sqliteviz-select rows"
:options="rowsToSelect"
:disabled="rowsToSelect.length === 0"
:multiple="true"
:hideSelected="true"
:closeOnSelect="true"
:showLabels="false"
:max="rowsToSelect.length"
:optionHeight="29"
openDirection="bottom"
placeholder=""
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<template #placeholder>Choose rows</template>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn v-model="rowOrder" class="sort-btn" direction="row" />
</div>
<div class="row aggregator">
<label>Aggregator</label>
<multiselect
v-model="aggregator"
class="sqliteviz-select short aggregator"
:options="aggregators"
label="name"
trackBy="name"
:closeOnSelect="true"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose a function"
>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<multiselect
v-show="valCount > 0"
v-model="val1"
class="sqliteviz-select aggr-arg"
:options="keyNames"
:disabled="keyNames.length === 0"
:closeOnSelect="true"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose an argument"
/>
<multiselect
v-show="valCount > 1"
v-model="val2"
class="sqliteviz-select aggr-arg"
:options="keyNames"
:disabled="keyNames.length === 0"
:closeOnSelect="true"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose a second argument"
/>
</div>
<div class="row">
<label>View</label>
<multiselect
v-model="renderer"
class="sqliteviz-select short renderer"
:options="renderers"
label="name"
trackBy="name"
:closeOnSelect="true"
:allowEmpty="false"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose a view"
>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn v-model="colOrder" class="sort-btn" direction="col" />
</div>
<div class="row">
<label>Rows</label>
<multiselect
v-model="rows"
class="sqliteviz-select rows"
:options="rowsToSelect"
:disabled="rowsToSelect.length === 0"
:multiple="true"
:hideSelected="true"
:closeOnSelect="true"
:showLabels="false"
:max="rowsToSelect.length"
:optionHeight="29"
openDirection="bottom"
placeholder=""
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<template #placeholder>Choose rows</template>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn v-model="rowOrder" class="sort-btn" direction="row" />
</div>
<div class="row aggregator">
<label>Aggregator</label>
<multiselect
v-model="aggregator"
class="sqliteviz-select short aggregator"
:options="aggregators"
label="name"
trackBy="name"
:closeOnSelect="true"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose a function"
>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<multiselect
v-show="valCount > 0"
v-model="val1"
class="sqliteviz-select aggr-arg"
:options="keyNames"
:disabled="keyNames.length === 0"
:closeOnSelect="true"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose an argument"
/>
<multiselect
v-show="valCount > 1"
v-model="val2"
class="sqliteviz-select aggr-arg"
:options="keyNames"
:disabled="keyNames.length === 0"
:closeOnSelect="true"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose a second argument"
/>
</div>
<div class="row">
<label>View</label>
<multiselect
v-model="renderer"
class="sqliteviz-select short renderer"
:options="renderers"
label="name"
trackBy="name"
:closeOnSelect="true"
:allowEmpty="false"
:showLabels="false"
:hideSelected="true"
:optionHeight="29"
openDirection="bottom"
placeholder="Choose a view"
>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
</div>
</div>
<span class="switcher" @click="collapsed = !collapsed">
{{ collapsed ? 'Show pivot settings' : 'Hide pivot settings' }}
</span>
</div>
</template>
@@ -158,6 +163,7 @@ export default {
const rendererName =
(this.modelValue && this.modelValue.rendererName) || 'Table'
return {
collapsed: false,
renderer: {
name: rendererName,
fun: $.pivotUtilities.renderers[rendererName]
@@ -285,6 +291,9 @@ export default {
margin-left: 12px;
flex-shrink: 0;
}
.collapsed {
display: none;
}
.switcher {
display: block;

View File

@@ -5,7 +5,6 @@
result is not empty.
</div>
<pivot-ui
v-show="showViewSettings"
v-model="pivotOptions"
:keyNames="columns"
@update="$emit('update')"
@@ -31,7 +30,7 @@ import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/pivot.css'
import PivotUi from './PivotUi/index.vue'
import PivotUi from './PivotUi'
import pivotHelper from './pivotHelper'
import Chart from '@/views/MainView/Workspace/Tabs/Tab/DataView/Chart'
import chartHelper from '@/lib/chartHelper'
@@ -48,8 +47,7 @@ export default {
dataSources: Object,
initOptions: Object,
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
showViewSettings: Boolean
exportToSvgEnabled: Boolean
},
emits: [
'loadingImageCompleted',
@@ -127,9 +125,6 @@ export default {
},
pivotOptions() {
this.show()
},
showViewSettings() {
this.handleResize()
}
},
created() {
@@ -227,12 +222,12 @@ export default {
async prepareCopy() {
if (this.viewCustomChart) {
return this.$refs.customChart.prepareCopy()
return await this.$refs.customChart.prepareCopy()
}
if (this.viewStandartChart) {
return chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
return await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
}
return pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
return await pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
},
async saveAsSvg() {
@@ -326,8 +321,4 @@ export default {
.pivot-output:empty {
flex-grow: 0;
}
:deep(.js-plotly-plot) {
height: 100%;
}
</style>

View File

@@ -7,17 +7,14 @@
v-model:exportToPngEnabled="exportToPngEnabled"
v-model:exportToSvgEnabled="exportToSvgEnabled"
v-model:exportToHtmlEnabled="exportToHtmlEnabled"
v-model:exportToClipboardEnabled="exportToClipboardEnabled"
:initOptions="initOptionsByMode[mode]"
:data-sources="dataSource"
:showViewSettings="showViewSettings"
@loading-image-completed="loadingImage = false"
@update="$emit('update')"
/>
</div>
<side-tool-bar panel="dataView" @switch-to="$emit('switchTo', $event)">
<icon-button
ref="chartBtn"
:active="mode === 'chart'"
tooltip="Switch to chart"
tooltipPosition="top-left"
@@ -35,7 +32,6 @@
<pivot-icon />
</icon-button>
<icon-button
ref="graphBtn"
:active="mode === 'graph'"
tooltip="Switch to graph"
tooltipPosition="top-left"
@@ -47,19 +43,6 @@
<div class="side-tool-bar-divider" />
<icon-button
ref="settingsBtn"
:active="showViewSettings"
tooltip="Toggle visualisation settings visibility"
tooltipPosition="top-left"
@click="showViewSettings = !showViewSettings"
>
<settings-icon />
</icon-button>
<div class="side-tool-bar-divider" />
<icon-button
ref="pngExportBtn"
:disabled="!exportToPngEnabled || loadingImage"
:loading="loadingImage"
tooltip="Save as PNG image"
@@ -89,7 +72,6 @@
</icon-button>
<icon-button
ref="copyToClipboardBtn"
:disabled="!exportToClipboardEnabled"
:loading="copyingImage"
tooltip="Copy visualisation to clipboard"
tooltipPosition="top-left"
@@ -100,10 +82,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"
@@ -113,21 +95,20 @@
</template>
<script>
import Chart from './Chart/index.vue'
import Pivot from './Pivot/index.vue'
import Graph from './Graph/index.vue'
import Chart from './Chart'
import Pivot from './Pivot'
import Graph from './Graph'
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 SettingsIcon from '@/components/svg/settings.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.vue'
import loadingDialog from '@/components/LoadingDialog'
import time from '@/lib/utils/time'
import events from '@/lib/utils/events'
@@ -142,7 +123,6 @@ export default {
ChartIcon,
PivotIcon,
GraphIcon,
SettingsIcon,
ExportToSvgIcon,
PngIcon,
HtmlIcon,
@@ -161,7 +141,6 @@ export default {
exportToPngEnabled: true,
exportToSvgEnabled: true,
exportToHtmlEnabled: true,
exportToClipboardEnabled: true,
loadingImage: false,
copyingImage: false,
preparingCopy: false,
@@ -170,9 +149,7 @@ export default {
chart: this.initMode === 'chart' ? this.initOptions : null,
pivot: this.initMode === 'pivot' ? this.initOptions : null,
graph: this.initMode === 'graph' ? this.initOptions : null
},
showLoadingDialog: false,
showViewSettings: true
}
}
},
computed: {
@@ -184,7 +161,6 @@ export default {
mode(newMode, oldMode) {
this.$emit('update')
this.exportToPngEnabled = true
this.exportToClipboardEnabled = true
this.initOptionsByMode[oldMode] = this.getOptionsForSave()
}
},
@@ -215,13 +191,14 @@ export default {
async prepareCopy() {
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.showLoadingDialog = true
this.$modal.show('prepareCopy')
const t0 = performance.now()
await time.sleep(0)
this.dataToCopy = await this.$refs.viewComponent.prepareCopy()
const t1 = performance.now()
if (t1 - t0 < 950) {
this.$modal.hide('prepareCopy')
this.copyToClipboard()
} else {
this.preparingCopy = false
@@ -234,13 +211,14 @@ export default {
)
}
},
copyToClipboard() {
async copyToClipboard() {
cIo.copyImage(this.dataToCopy)
this.showLoadingDialog = false
this.$modal.hide('prepareCopy')
this.exportSignal('clipboard')
},
cancelCopy() {
this.dataToCopy = null
this.$modal.hide('prepareCopy')
},
saveAsSvg() {
@@ -261,9 +239,7 @@ export default {
events.send(
this.mode === 'chart' || this.plotlyInPivot
? 'viz_plotly.export'
: this.mode === 'graph'
? 'viz_graph.export'
: 'viz_pivot.export',
: 'viz_pivot.export',
null,
eventLabels
)

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,8 +190,7 @@ export default {
viewRecord: false,
defaultPage: 1,
defaultSelectedCell: null,
enableTeleport: this.$store.state.isWorkspaceVisible,
showLoadingDialog: false
enableTeleport: this.$store.state.isWorkspaceVisible
}
},
computed: {
@@ -265,13 +264,14 @@ export default {
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.showLoadingDialog = true
this.$modal.show('prepareCSVCopy')
const t0 = performance.now()
await time.sleep(0)
this.dataToCopy = csv.serialize(this.result)
const t1 = performance.now()
if (t1 - t0 < 950) {
this.$modal.hide('prepareCSVCopy')
this.copyToClipboard()
} else {
this.preparingCopy = false
@@ -287,11 +287,12 @@ export default {
copyToClipboard() {
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
this.showLoadingDialog = false
this.$modal.hide('prepareCSVCopy')
},
cancelCopy() {
this.dataToCopy = null
this.$modal.hide('prepareCSVCopy')
},
toggleViewValuePanel() {

View File

@@ -63,9 +63,7 @@ export default {
border-left: 1px solid var(--color-border-light);
padding: 6px;
}
</style>
<style>
.side-tool-bar-divider {
width: 26px;
height: 1px;

View File

@@ -42,8 +42,8 @@ export default {
) {
const stmt = [
'/*',
' * Your database is empty. In order to start building data visualisations',
' * you should create tables and insert data into them.',
' * Your database is empty. In order to start building charts',
' * you should create a table and insert data into it.',
' */',
'CREATE TABLE house',
'(',
@@ -54,20 +54,7 @@ export default {
"('Gryffindor', 100),",
"('Hufflepuff', 90),",
"('Ravenclaw', 95),",
"('Slytherin', 80);",
'',
'CREATE TABLE student',
'(',
' id INTEGER,',
' name TEXT,',
' house TEXT',
');',
'INSERT INTO student VALUES',
"(1, 'Harry Potter', 'Gryffindor'),",
"(2, 'Ron Weasley', 'Gryffindor'),",
"(3, 'Draco Malfoy', 'Slytherin'),",
"(4, 'Luna Lovegood', 'Ravenclaw'),",
"(5, 'Cedric Diggory', 'Hufflepuff');"
"('Slytherin', 80);"
].join('\n')
const tabId = await this.$store.dispatch('addTab', { query: stmt })

View File

@@ -1,22 +1,13 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { shallowMount, mount } from '@vue/test-utils'
import { shallowMount } from '@vue/test-utils'
import { createStore } from 'vuex'
import App from '@/App.vue'
import App from '@/App'
import storedInquiries from '@/lib/storedInquiries'
import actions from '@/store/actions'
import mutations from '@/store/mutations'
import { nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from '@/router'
describe('App.vue', () => {
let clock
beforeEach(() => {
clock = sinon.useFakeTimers()
})
afterEach(() => {
sinon.restore()
})
@@ -68,167 +59,4 @@ 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' }
])
})
it('Closes with saving and does not change the next tab', async () => {
const inquiries = [
{
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
createdAt: '2020-11-07T20:57:04.492Z'
},
{
id: 2,
name: 'bar',
query: 'SELECT * FROM bar',
viewType: 'chart',
viewOptions: {},
createdAt: '2020-11-07T20:57:04.492Z'
}
]
sinon.stub(storedInquiries, 'getStoredInquiries').returns(inquiries)
const tab1 = {
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
result: {
columns: ['name', 'points'],
values: {
name: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
points: [100, 90, 95, 80]
}
},
isSaved: false
}
const tab2 = {
id: 2,
name: 'bar',
query: 'SELECT * FROM bar',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'hidden',
dataView: 'bottom'
},
result: {
columns: ['id'],
values: {
id: [1, 2, 3]
}
},
isSaved: true
}
// mock store state
const state = {
tabs: [tab1, tab2],
currentTabId: 1,
currentTab: tab1,
db: {},
inquiries
}
const store = createStore({ state, mutations, actions })
const router = createRouter({
history: createWebHistory(),
routes: routes
})
router.push('/workspace')
// After this line, router is ready
await router.isReady()
const wrapper = mount(App, {
attachTo: document.body,
global: {
stubs: {
'router-link': true,
teleport: true,
transition: false,
schema: true,
AppDiagnosticInfo: true,
DataView: {
template: '<div></div>',
methods: { getOptionsForSave: sinon.stub() }
}
},
plugins: [store, router]
}
})
// click on the close icon of the first tab
const firstTabCloseIcon = wrapper.findAll('.tab')[0].find('.close-icon')
await firstTabCloseIcon.trigger('click')
// find 'Save and close' in the dialog
const closeBtn = wrapper
.findAll('.dialog-buttons-container button')
.find(button => button.text() === 'Save and close')
// click 'Save and close' in the dialog
await closeBtn.trigger('click')
await nextTick()
await nextTick()
// check that tab is closed
expect(wrapper.findAllComponents({ name: 'Tab' })).to.have.lengthOf(1)
// check that the open tab didn't change
const firstTab = wrapper.findComponent({ name: 'Tab' })
expect(firstTab.props('tab').name).to.equal('bar')
expect(firstTab.props('tab').result).to.eql({
columns: ['id'],
values: {
id: [1, 2, 3]
}
})
expect(firstTab.props('tab')).to.eql(tab2)
// check that the dialog is closed
await clock.tick(100)
await nextTick()
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
wrapper.unmount()
})
})

File diff suppressed because it is too large Load Diff

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.key > 0 -- because the filenames start with the separator
WHERE p.id > 1 -- because the filenames start with the separator
`)
expect(actual.values).to.eql({
path: [

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -15,7 +15,6 @@ describe('tab.js', () => {
query: undefined,
viewOptions: undefined,
isPredefined: undefined,
updatedAt: undefined,
viewType: 'chart',
result: null,
isGettingResults: false,
@@ -43,8 +42,7 @@ describe('tab.js', () => {
viewType: 'pivot',
viewOptions: 'this is view options object',
name: 'Foo inquiry',
createdAt: '2022-12-05T18:30:30',
updatedAt: '2022-12-06T18:30:30'
createdAt: '2022-12-05T18:30:30'
}
const newTab = new Tab(state, inquiry)
@@ -55,7 +53,6 @@ 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,8 +19,7 @@ describe('actions', () => {
tempName: 'Untitled',
viewType: 'chart',
viewOptions: undefined,
isSaved: false,
updatedAt: undefined
isSaved: false
})
expect(state.untitledLastIndex).to.equal(1)
@@ -31,8 +30,7 @@ describe('actions', () => {
tempName: 'Untitled 1',
viewType: 'chart',
viewOptions: undefined,
isSaved: false,
updatedAt: undefined
isSaved: false
})
expect(state.untitledLastIndex).to.equal(2)
})
@@ -42,16 +40,16 @@ describe('actions', () => {
tabs: [],
untitledLastIndex: 0
}
const inquiry = {
const tab = {
id: 1,
name: 'test',
query: 'SELECT * from foo',
viewType: 'chart',
viewOptions: 'an object with view options',
updatedAt: '2025-05-16T20:15:00Z'
isSaved: true
}
await addTab({ state }, inquiry)
expect(state.tabs[0]).to.include(inquiry)
await addTab({ state }, tab)
expect(state.tabs[0]).to.include(tab)
expect(state.untitledLastIndex).to.equal(0)
})
@@ -168,26 +166,21 @@ describe('actions', () => {
newName: 'foo'
}
)
expect(value.id).not.to.equal(tab.id)
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(value).to.have.property('createdAt').which.within(now, nowPlusMinute)
expect(state.inquiries).to.eql([value])
})
it('saveInquiry updates existing inquiry in the storage', async () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
it('save updates existing inquiry in the storage', async () => {
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo',
updatedAt: '2025-05-16T20:15:00Z',
name: null,
dataView: {
getOptionsForSave() {
return ['chart']
@@ -196,34 +189,34 @@ describe('actions', () => {
}
const state = {
inquiries: [
{
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo',
createdAt: '2025-05-15T16:30:00Z',
updatedAt: '2025-05-16T20:15:00Z'
}
],
inquiries: [],
tabs: [tab]
}
tab.query = 'select * from bar'
await saveInquiry({ state }, { inquiryTab: tab, newName: '' })
const first = await saveInquiry(
{ state },
{
inquiryTab: tab,
newName: 'foo'
}
)
tab.name = 'foo'
tab.query = 'select * from foo'
await saveInquiry({ state }, { inquiryTab: tab })
const inquiries = state.inquiries
const updatedTab = inquiries[0]
const second = inquiries[0]
expect(inquiries).has.lengthOf(1)
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)
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()
)
})
it("saveInquiry adds a new inquiry with new id if it's based on predefined inquiry", async () => {
it("save 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 = {
@@ -259,95 +252,6 @@ 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,8 +34,7 @@ describe('mutations', () => {
viewType: 'chart',
viewOptions: { here_are: 'chart settings' },
isSaved: false,
isPredefined: false,
updatedAt: '2025-05-15T15:30:00Z'
isPredefined: false
}
const newValues = {
@@ -44,7 +43,6 @@ describe('mutations', () => {
query: 'SELECT * from bar',
viewType: 'pivot',
viewOptions: { here_are: 'pivot settings' },
updatedAt: '2025-05-15T16:30:00Z',
isSaved: true
}
@@ -60,7 +58,6 @@ describe('mutations', () => {
query: 'SELECT * from bar',
viewType: 'pivot',
viewOptions: { here_are: 'pivot settings' },
updatedAt: '2025-05-15T16:30:00Z',
isSaved: true
})
})

View File

@@ -1,20 +0,0 @@
export function waitCondition(condition, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (condition()) {
resolve()
return
}
const start = new Date().getTime()
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval)
resolve()
} else {
if (new Date().getTime() - start > timeoutMs) {
clearInterval(interval)
reject()
}
}
}, 500)
})
}

View File

@@ -6,8 +6,6 @@ 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
@@ -28,7 +26,7 @@ describe('MainMenu.vue', () => {
wrapper.unmount()
})
it('Create, Save and Save as are visible only on /workspace page', async () => {
it('Create and Save are visible only on /workspace page', async () => {
const state = {
currentTab: { query: '', execute: sinon.stub() },
tabs: [{}],
@@ -47,8 +45,6 @@ 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()
@@ -69,7 +65,7 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
})
it('Save and Save as are not visible if there is no tabs', () => {
it('Save is not visible if there is no tabs', () => {
const state = {
currentTab: null,
tabs: [],
@@ -87,8 +83,6 @@ 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)
})
@@ -117,12 +111,10 @@ 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 () => {
@@ -340,7 +332,7 @@ describe('MainMenu.vue', () => {
expect(wrapper.vm.createNewInquiry.callCount).to.equal(4)
})
it('Ctrl S calls onSave if the tab is unsaved and route path is /workspace', async () => {
it('Ctrl S calls checkInquiryBeforeSave if the tab is unsaved and route path is /workspace', async () => {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
@@ -361,115 +353,42 @@ describe('MainMenu.vue', () => {
plugins: [store]
}
})
sinon.stub(wrapper.vm, 'onSave')
sinon.stub(wrapper.vm, 'checkInquiryBeforeSave')
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.onSave.calledOnce).to.equal(true)
expect(wrapper.vm.checkInquiryBeforeSave.calledOnce).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
// tab is saved and route is /workspace
store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
expect(wrapper.vm.checkInquiryBeforeSave.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.onSave.calledTwice).to.equal(true)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
})
it('Ctrl Shift S calls onSaveAs if route path is /workspace', async () => {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: tab,
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 () => {
it('Saves the inquiry when no need the new name', 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: {}
}
@@ -482,8 +401,7 @@ describe('MainMenu.vue', () => {
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
viewOptions: []
})
}
const store = createStore({ state, mutations, actions })
@@ -528,8 +446,7 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true,
updatedAt: '2025-05-16T15:30:00Z'
isSaved: true
}
})
)
@@ -539,396 +456,6 @@ 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')
// 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')
// 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')
// 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]
}
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'
)
await clock.tick(100)
// 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')
// Hiding any dialog is done with tiny animation. Give time to finish it:
await clock.tick(100)
// Note: don't call nextTick before clock.tick. That leads to extra trap in
// trapStack and the test fails with focus-trap error in afterEach hook
// when unmount the component
// check that only one dialog open
expect(wrapper.findAll('.dialog.vfm').length).to.equal(1)
// 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')
// 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,
@@ -936,8 +463,7 @@ describe('MainMenu.vue', () => {
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false,
updatedAt: '2025-05-15T15:30:00Z'
isSaved: false
}
const state = {
currentTab: tab,
@@ -953,8 +479,7 @@ describe('MainMenu.vue', () => {
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
viewOptions: []
})
}
const store = createStore({ state, mutations, actions })
@@ -997,15 +522,14 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
})
it('Saves the new inquiry with a new name', async () => {
it('Saves the inquiry with a new name', async () => {
const tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false,
updatedAt: undefined
isSaved: false
}
const state = {
currentTab: tab,
@@ -1018,11 +542,10 @@ describe('MainMenu.vue', () => {
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo',
id: 2,
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
viewOptions: []
})
}
const store = createStore({ state, mutations, actions })
@@ -1060,6 +583,8 @@ describe('MainMenu.vue', () => {
.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)
@@ -1079,12 +604,11 @@ describe('MainMenu.vue', () => {
tab,
newValues: {
name: 'foo',
id: 2,
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
isSaved: true
}
})
)
@@ -1126,8 +650,7 @@ describe('MainMenu.vue', () => {
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
viewOptions: []
})
}
const store = createStore({ state, mutations, actions })
@@ -1168,6 +691,8 @@ describe('MainMenu.vue', () => {
.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)
@@ -1191,8 +716,7 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
isSaved: true
}
})
)
@@ -1200,6 +724,19 @@ describe('MainMenu.vue', () => {
// check that 'inquirySaved' event was triggered on eventBus
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
// We saved predefined inquiry, so the tab will be created again
// (because of new id) and it will be without sql result and has default view - table.
// That's why we need to restore data and view.
// Check that result and view are preserved in the currentTab:
expect(state.currentTab.viewType).to.equal('chart')
expect(state.currentTab.result).to.eql({
columns: ['id', 'name'],
values: [
[1, 'Harry Potter'],
[2, 'Drako Malfoy']
]
})
})
it('Cancel saving', async () => {
@@ -1224,7 +761,7 @@ describe('MainMenu.vue', () => {
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart'
chart: []
})
}
const store = createStore({ state, mutations, actions })
@@ -1272,110 +809,4 @@ 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')
// 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

@@ -15,6 +15,7 @@ describe('Chart.vue', () => {
})
it('getOptionsForSave called with proper arguments', () => {
// mount the component
const wrapper = mount(Chart, {
global: {
mocks: { $store }
@@ -29,6 +30,7 @@ describe('Chart.vue', () => {
})
it('emits update when plotly updates', async () => {
// mount the component
const wrapper = mount(Chart, {
global: {
mocks: { $store }
@@ -46,6 +48,7 @@ describe('Chart.vue', () => {
points: [80]
}
// mount the component
const wrapper = mount(Chart, {
props: {
dataSources,
@@ -184,8 +187,7 @@ describe('Chart.vue', () => {
const wrapper = mount(Chart, {
attachTo: document.body,
props: {
dataSources,
showViewSettings: true
dataSources
},
global: {
mocks: { $store }
@@ -205,41 +207,4 @@ describe('Chart.vue', () => {
expect(wrapper.find('.Select__menu').text()).to.contain('name' + 'points')
wrapper.unmount()
})
it('hides and shows controls depending on showViewSettings and resizes the plot', async () => {
const wrapper = mount(Chart, {
attachTo: document.body,
props: {
dataSources: null,
showViewSettings: false
},
global: {
mocks: { $store }
}
})
// don't call flushPromises here, otherwize resize observer will be call to often
// which causes ResizeObserver loop completed with undelivered notifications.
await nextTick()
const plot = wrapper.find('.svg-container').wrapperElement
await flushPromises()
const initialPlotWidth = plot.scrollWidth
const initialPlotHeight = plot.scrollHeight
expect(wrapper.find('.plotly_editor .editor_controls').exists()).to.equal(
false
)
await wrapper.setProps({ showViewSettings: true })
await flushPromises()
expect(plot.scrollWidth).not.to.equal(initialPlotWidth)
expect(plot.scrollHeight).to.equal(initialPlotHeight)
expect(wrapper.find('.plotly_editor .editor_controls').exists()).to.equal(
true
)
wrapper.unmount()
})
})

View File

@@ -1,9 +1,8 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView/index.vue'
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 } }
@@ -15,7 +14,7 @@ describe('DataView.vue', () => {
it('emits update on mode changing', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store }
stubs: { chart: true }
}
})
@@ -29,10 +28,7 @@ describe('DataView.vue', () => {
it('method getOptionsForSave calls the same method of the current view component', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
mocks: { $store }
}
})
@@ -56,34 +52,19 @@ describe('DataView.vue', () => {
expect(wrapper.vm.getOptionsForSave()).to.eql({
here_are: 'pivot_settings'
})
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
const graph = wrapper.findComponent({ name: 'graph' }).vm
sinon
.stub(graph, 'getOptionsForSave')
.returns({ here_are: 'graph_settings' })
expect(wrapper.vm.getOptionsForSave()).to.eql({
here_are: 'graph_settings'
})
wrapper.unmount()
})
it('method saveAsSvg calls the same method of the current view component', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
mocks: { $store }
}
})
// Find chart and spy the method
const chart = wrapper.findComponent({ name: 'Chart' }).vm
sinon.stub(chart, 'saveAsSvg')
sinon.spy(chart, 'saveAsSvg')
// Export to svg
const svgBtn = wrapper.findComponent({ ref: 'svgExportBtn' })
@@ -96,7 +77,7 @@ describe('DataView.vue', () => {
// Find pivot and spy the method
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.stub(pivot, 'saveAsSvg')
sinon.spy(pivot, 'saveAsSvg')
// Switch to Custom Chart renderer
pivot.pivotOptions.rendererName = 'Custom chart'
@@ -105,22 +86,13 @@ describe('DataView.vue', () => {
// Export to svg
await svgBtn.trigger('click')
expect(pivot.saveAsSvg.calledOnce).to.equal(true)
// Switch to graph - svg disabled
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
expect(svgBtn.attributes('disabled')).to.not.equal(undefined)
wrapper.unmount()
})
it('method saveAsHtml calls the same method of the current view component', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
mocks: { $store }
}
})
@@ -141,76 +113,9 @@ describe('DataView.vue', () => {
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsHtml')
// Export to html
// Export to svg
await htmlBtn.trigger('click')
expect(pivot.saveAsHtml.calledOnce).to.equal(true)
// Switch to graph - htmlBtn disabled
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
expect(htmlBtn.attributes('disabled')).to.not.equal(undefined)
wrapper.unmount()
})
it('method saveAsPng calls the same method of the current view component', async () => {
const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, {
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
// Find chart and stub the method
const chart = wrapper.findComponent({ name: 'Chart' }).vm
sinon.stub(chart, 'saveAsPng').callsFake(() => {
chart.$emit('loadingImageCompleted')
})
// Export to png
const pngBtn = wrapper.findComponent({ ref: 'pngExportBtn' })
await pngBtn.trigger('click')
await clock.tick(0)
expect(chart.saveAsPng.calledOnce).to.equal(true)
// Switch to pivot
const pivotBtn = wrapper.findComponent({ ref: 'pivotBtn' })
await pivotBtn.trigger('click')
// Find pivot and stub the method
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.stub(pivot, 'saveAsPng').callsFake(() => {
pivot.$emit('loadingImageCompleted')
})
// Export to png
await pngBtn.trigger('click')
await clock.tick(0)
expect(pivot.saveAsPng.calledOnce).to.equal(true)
// Switch to graph
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
// Save as png is disabled because there is no data
expect(pngBtn.attributes('disabled')).to.not.equal(undefined)
await wrapper.setProps({ dataSource: { doc: [] } })
// Find graph and stub the method
const graph = wrapper.findComponent({ name: 'graph' }).vm
sinon.stub(graph, 'saveAsPng').callsFake(() => {
graph.$emit('loadingImageCompleted')
})
// Export to png
await pngBtn.trigger('click')
await clock.tick(0)
expect(graph.saveAsPng.calledOnce).to.equal(true)
wrapper.unmount()
})
@@ -241,7 +146,6 @@ 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,
@@ -261,7 +165,7 @@ describe('DataView.vue', () => {
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Copy to clipboard'
)
@@ -276,10 +180,11 @@ 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 .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
// ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('Image is ready')
@@ -291,13 +196,12 @@ describe('DataView.vue', () => {
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm').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,
@@ -322,9 +226,10 @@ describe('DataView.vue', () => {
// Wait untill prepareCopy is finished
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
await nextTick()
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount()
@@ -365,57 +270,9 @@ describe('DataView.vue', () => {
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount()
})
it('saves visualisation options and restores them when switch between modes', async () => {
const wrapper = mount(DataView, {
props: {
initMode: 'graph',
initOptions: { test_options: 'graph_options_from_inquiery' }
},
global: {
mocks: { $store },
stubs: {
chart: true,
graph: true,
pivot: true
}
}
})
const getOptionsForSaveStub = sinon.stub(wrapper.vm, 'getOptionsForSave')
expect(
wrapper.findComponent({ name: 'graph' }).props('initOptions')
).to.eql({ test_options: 'graph_options_from_inquiery' })
getOptionsForSaveStub.returns({ test_options: 'latest_graph_options' })
const chartBtn = wrapper.findComponent({ ref: 'chartBtn' })
await chartBtn.trigger('click')
getOptionsForSaveStub.returns({ test_options: 'chart_settings' })
const pivotBtn = wrapper.findComponent({ ref: 'pivotBtn' })
await pivotBtn.trigger('click')
getOptionsForSaveStub.returns({ test_options: 'pivot_settings' })
await chartBtn.trigger('click')
expect(
wrapper.findComponent({ name: 'chart' }).props('initOptions')
).to.eql({ test_options: 'chart_settings' })
await pivotBtn.trigger('click')
expect(
wrapper.findComponent({ name: 'pivot' }).props('initOptions')
).to.eql({ test_options: 'pivot_settings' })
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
expect(
wrapper.findComponent({ name: 'graph' }).props('initOptions')
).to.eql({ test_options: 'latest_graph_options' })
})
})

View File

@@ -1,504 +0,0 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount, flushPromises } from '@vue/test-utils'
import Graph from '@/views/MainView/Workspace/Tabs/Tab/DataView/Graph/index.vue'
function getPixels(canvas) {
const context = canvas.getContext('webgl2')
const width = context.canvas.width
const height = context.canvas.height
// Create arrays to hold the pixel data
const pixels = new Uint8Array(width * height * 4)
// Read pixels from canvas
context.readPixels(
0,
0,
width,
height,
context.RGBA,
context.UNSIGNED_BYTE,
pixels
)
return pixels.join(' ')
}
describe('Graph.vue', () => {
const $store = { state: { isWorkspaceVisible: true } }
afterEach(() => {
sinon.restore()
})
it('shows message when no data', () => {
const wrapper = mount(Graph, {
global: {
stubs: {
GraphEditor: true
}
},
attachTo: document.body
})
expect(wrapper.find('.no-data').isVisible()).to.equal(true)
expect(wrapper.find('.invalid-data').isVisible()).to.equal(false)
wrapper.unmount()
})
it('shows message when data is invalid', () => {
const wrapper = mount(Graph, {
props: {
dataSources: {
column1: ['value1', 'value2']
}
},
global: {
stubs: {
GraphEditor: true
}
},
attachTo: document.body
})
expect(wrapper.find('.no-data').isVisible()).to.equal(false)
expect(wrapper.find('.invalid-data').isVisible()).to.equal(true)
wrapper.unmount()
})
it('emits update when graph editor updates', async () => {
const wrapper = mount(Graph, {
global: {
stubs: {
GraphEditor: true
}
}
})
wrapper.findComponent({ ref: 'graphEditor' }).vm.$emit('update')
expect(wrapper.emitted('update')).to.have.lengthOf(1)
})
it('the graph resizes when the container resizes', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
},
layout: {
type: 'circular',
options: null
}
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const container =
wrapper.find('.graph-container').wrapperElement.parentElement
const canvas = wrapper.find('canvas.sigma-nodes').wrapperElement
const initialContainerWidth = container.scrollWidth
const initialContainerHeight = container.scrollHeight
const initialCanvasWidth = canvas.scrollWidth
const initialCanvasHeight = canvas.scrollHeight
const newContainerWidth = initialContainerWidth * 2 || 1000
const newContainerHeight = initialContainerHeight * 2 || 2000
container.style.width = `${newContainerWidth}px`
container.style.height = `${newContainerHeight}px`
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialCanvasWidth)
expect(canvas.scrollHeight).not.to.equal(initialCanvasHeight)
wrapper.unmount()
})
it('nodes and edges are rendered', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
showViewSettings: true,
dataSources: {
doc: [
'{"object_type": 0, "node_id": 1}',
'{"object_type": 0, "node_id": 2}',
'{"object_type": 1, "source": 1, "target": 2}'
]
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const container =
wrapper.find('.graph-container').wrapperElement.parentElement
container.style.height = '400px'
await wrapper
.find('.test_object_type_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
let options = wrapper.findAll('.Select__menu .Select__option')
await options[0].trigger('click')
const nodeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
await wrapper
.find('.test_node_id_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[1].trigger('click')
await wrapper
.find('.test_edge_source_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[2].trigger('click')
await wrapper
.find('.test_edge_target_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[3].trigger('click')
const nodeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
expect(nodeCanvasPixelsBefore).not.equal(nodeCanvasPixelsAfter)
expect(edgeCanvasPixelsBefore).not.equal(edgeCanvasPixelsAfter)
wrapper.unmount()
})
it('rerenders when dataSource changes but does not rerender if dataSources is empty', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
showViewSettings: true,
dataSources: {
doc: [
'{"object_type": 0, "node_id": 1}',
'{"object_type": 0, "node_id": 2}',
'{"object_type": 1, "source": 1, "target": 2}'
]
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const container =
wrapper.find('.graph-container').wrapperElement.parentElement
container.style.height = '400px'
await wrapper
.find('.test_object_type_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
let options = wrapper.findAll('.Select__menu .Select__option')
await options[0].trigger('click')
await wrapper
.find('.test_node_id_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[1].trigger('click')
await wrapper
.find('.test_edge_source_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[2].trigger('click')
await wrapper
.find('.test_edge_target_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[3].trigger('click')
const nodeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
await wrapper.setProps({
dataSources: {
doc: [
'{"object_type": 0, "node_id": 1}',
'{"object_type": 0, "node_id": 2}',
'{"object_type": 0, "node_id": 3}',
'{"object_type": 1, "source": 1, "target": 2}',
'{"object_type": 1, "source": 1, "target": 3}'
]
}
})
const nodeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
expect(nodeCanvasPixelsBefore).not.equal(nodeCanvasPixelsAfter)
expect(edgeCanvasPixelsBefore).not.equal(edgeCanvasPixelsAfter)
await wrapper.setProps({
dataSources: null
})
const nodeCanvasPixelsAfterEmtyData = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsAfterEmtyData = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
expect(nodeCanvasPixelsAfterEmtyData).equal(nodeCanvasPixelsAfter)
expect(edgeCanvasPixelsAfterEmtyData).equal(edgeCanvasPixelsAfter)
wrapper.unmount()
})
it('saveAsPng', async () => {
const wrapper = mount(Graph, {
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
},
layout: {
type: 'circular',
options: null
}
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
sinon.stub(wrapper.vm.$refs.graphEditor, 'saveAsPng')
await wrapper.vm.saveAsPng()
expect(wrapper.emitted().loadingImageCompleted.length).to.equal(1)
})
it('hides and shows controls depending on showViewSettings and resizes the graph', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
},
layout: {
type: 'circular',
options: null
}
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const canvas = wrapper.find('canvas.sigma-nodes').wrapperElement
const initialPlotWidth = canvas.scrollWidth
const initialPlotHeight = canvas.scrollHeight
expect(
wrapper.find('.plotly_editor .editor_controls').isVisible()
).to.equal(false)
await wrapper.setProps({ showViewSettings: true })
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialPlotWidth)
expect(canvas.scrollHeight).to.equal(initialPlotHeight)
expect(
wrapper.find('.plotly_editor .editor_controls').isVisible()
).to.equal(true)
wrapper.unmount()
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount, flushPromises } from '@vue/test-utils'
import Pivot from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/index.vue'
import Pivot from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot'
import chartHelper from '@/lib/chartHelper'
import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
@@ -586,48 +586,4 @@ describe('Pivot.vue', () => {
wrapper.unmount()
})
it('hides or shows controlls depending on showViewSettings and resizes standart chart', async () => {
const wrapper = mount(Pivot, {
global: {
mocks: { $store: { state: { isWorkspaceVisible: true } } }
},
props: {
dataSources: {
item: ['foo', 'bar', 'bar', 'bar'],
year: [2021, 2021, 2020, 2020]
},
initOptions: {
rows: ['item'],
cols: ['year'],
colOrder: 'key_a_to_z',
rowOrder: 'key_a_to_z',
aggregatorName: 'Count',
vals: [],
renderer: $.pivotUtilities.renderers['Bar Chart'],
rendererName: 'Bar Chart'
}
},
attachTo: container
})
expect(wrapper.find('.pivot-ui').isVisible()).to.equal(false)
await flushPromises()
const plot = wrapper.find('.svg-container').wrapperElement
const initialPlotHeight = plot.scrollHeight
await wrapper.setProps({ showViewSettings: true })
expect(wrapper.find('.pivot-ui').isVisible()).to.equal(true)
await flushPromises()
const plotAfterResize = wrapper.find('.svg-container').wrapperElement
expect(plotAfterResize.scrollWidth.scrollHeight).not.to.equal(
initialPlotHeight
)
wrapper.unmount()
})
})

View File

@@ -78,7 +78,7 @@ describe('RunResult.vue', () => {
await nextTick()
// The dialog is shown...
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm').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 .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm').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 .vfm__content').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
wrapper.unmount()
})
@@ -139,10 +139,11 @@ describe('RunResult.vue', () => {
// Switch to microtasks (let serialize run)
await clock.tick(0)
await nextTick()
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount()
@@ -187,7 +188,7 @@ describe('RunResult.vue', () => {
.trigger('click')
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount()

View File

@@ -5,9 +5,6 @@ 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
@@ -49,7 +46,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
chart: [],
isSaved: true
},
{
@@ -57,7 +54,7 @@ describe('Tabs.vue', () => {
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
chart: [],
isSaved: false
}
],
@@ -100,7 +97,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
chart: [],
isSaved: true
},
{
@@ -108,7 +105,7 @@ describe('Tabs.vue', () => {
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
chart: [],
isSaved: false
}
],
@@ -439,7 +436,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
chart: [],
isSaved: true
},
{
@@ -447,7 +444,7 @@ describe('Tabs.vue', () => {
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
chart: [],
isSaved: false
}
],
@@ -480,7 +477,7 @@ describe('Tabs.vue', () => {
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
chart: [],
isSaved: true
}
],
@@ -504,216 +501,4 @@ 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()
})
})