1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2026-03-22 05:56:16 +08:00

Compare commits

...

63 Commits

Author SHA1 Message Date
lana-k
c2c376219f fix lint 2026-02-28 17:59:39 +01:00
lana-k
0199415dde 0.29.0 2026-02-28 17:44:17 +01:00
lana-k
534b186d76 tests for graph editor 2026-02-28 16:53:43 +01:00
lana-k
4f6efb5bda test for DataView 2026-02-26 22:32:24 +01:00
lana-k
2c0b8f9124 enable webgl in chromium 2026-02-25 22:58:28 +01:00
lana-k
5265f5493e splitpanes test 2026-02-24 21:28:11 +01:00
lana-k
c0e59f6fb8 update run result test 2026-02-24 21:02:06 +01:00
lana-k
7471744633 add tests for Value Viewer 2026-02-24 20:53:03 +01:00
lana-k
e6e5efa8c6 tests for graphHelper 2026-02-22 22:30:18 +01:00
lana-k
57c36b3900 tests for migration 2026-02-07 22:08:39 +01:00
lana-k
1e8c1761e6 #133 highlight nodes and edges 2026-02-07 21:18:49 +01:00
lana-k
dd30e17ff5 #133 Add node data viewer 2026-01-25 22:15:48 +01:00
lana-k
4e5adc147f #132 node opacity 2026-01-22 22:25:36 +01:00
lana-k
7edc196a02 change repo structure 2026-01-15 21:53:12 +01:00
lana-k
85b5a200e2 fix tinycolor2 bundle 2025-12-27 21:11:01 +01:00
lana-k
a0ef93921f #131 fix label color 2025-12-26 20:56:08 +01:00
lana-k
859cd2ccfc #129 fix icon 2025-12-25 12:29:28 +01:00
lana-k
a59946c09d remove karma config 2025-12-24 22:14:48 +01:00
lana-k
7b06b3d9c8 uninstall mesa 2025-12-24 22:06:39 +01:00
lana-k
ced933f497 revert firefox base and env 2025-12-24 21:59:23 +01:00
lana-k
cda368f109 xvfb 2025-12-24 21:49:53 +01:00
lana-k
df67466c2f firefox base 2025-12-24 21:41:29 +01:00
lana-k
528549ae5a LIBGL_ALWAYS_SOFTWARE 2025-12-24 21:36:47 +01:00
lana-k
20f4dcc645 fix package names 2025-12-24 17:51:40 +01:00
lana-k
b8353ef0ce install mesa 2025-12-24 17:49:07 +01:00
lana-k
7975f419c9 anoter settings 2025-12-24 17:40:18 +01:00
lana-k
72aa0dd80b another settings 2025-12-24 17:30:05 +01:00
lana-k
e000ee71fc ensure webgl is enabled infirefox 2025-12-24 17:25:25 +01:00
lana-k
b6a12668d3 #43 fix lint errors 2025-12-24 16:17:49 +01:00
lana-k
713f5ac768 #43 graph 0.28.0 2025-12-23 21:41:17 +01:00
lana-k
5492609c3a update readme 2025-12-23 21:28:32 +01:00
lana-k
8bfd0f5944 tests 2025-12-23 21:15:44 +01:00
lana-k
a8006bcf52 tests 2025-12-17 21:26:57 +01:00
lana-k
1463f93bb0 tests for layouts 2025-12-13 17:57:48 +01:00
lana-k
5108495430 test for canvas 2025-12-13 13:00:13 +01:00
lana-k
d28968e539 tests 2025-12-07 19:56:16 +01:00
lana-k
68221cba6d tests 2025-11-15 14:29:41 +01:00
lana-k
65c1c18fcb tests 2025-11-08 22:23:38 +01:00
lana-k
d7db6a0f5d fix tests 2025-11-02 12:31:32 +01:00
lana-k
0a2af0bba3 events 2025-11-01 21:25:56 +01:00
lana-k
e4b35bac0a skip node if there is no node id 2025-11-01 19:48:22 +01:00
lana-k
3d1e822cdc link to docs, disable some settings, check result set 2025-11-01 15:49:34 +01:00
lana-k
3d6479be7a visualisation settings toggle 2025-10-28 22:51:13 +01:00
lana-k
218ab52ab3 autostart, reset and fixes 2025-10-28 13:39:38 +01:00
lana-k
f178937440 export and background 2025-10-28 13:39:38 +01:00
lana-k
411bd694c0 wip 2025-10-28 13:38:17 +01:00
lana-k
d2969de127 wip 2025-10-28 13:38:17 +01:00
lana-k
b59c21c14e update tests 2025-10-19 18:25:03 +02:00
lana-k
4ed4b54a28 fix warnings 2025-10-17 21:01:40 +02:00
lana-k
2c2bb7d6d3 0.27.1 2025-10-16 22:29:56 +02:00
lana-k
efbd985b36 #128 tests 2025-10-16 22:28:33 +02:00
lana-k
9cf7d0e5dc #128 fix save and close 2025-10-09 22:49:54 +02:00
lana-k
0a8c09b58d #127 fix for new inquiry 2025-10-08 21:04:17 +02:00
lana-k
931cf380bc #127 tests 2025-10-08 19:39:56 +02:00
lana-k
f0f96ac663 tests 2025-10-05 20:59:34 +02:00
lana-k
45530cc9d6 add save as event 2025-10-05 14:27:50 +02:00
lana-k
6fbf75b601 fix tests 2025-10-03 22:13:33 +02:00
lana-k
d3fbf08569 #31 fix deleting inquiry 2025-09-29 21:17:36 +02:00
lana-k
be6a19a30f #127 fix copy to clipboard 2025-09-28 22:11:18 +02:00
lana-k
07d7a9d54b #31 handle concurrent saving 2025-09-27 21:59:32 +02:00
lana-k
cdd925b8af #16 save as 2025-09-27 17:01:50 +02:00
lana-k
12fa0749b1 Update package.json 2025-07-30 23:27:35 +02:00
saaj
75bf849823 Build SQLite 3.50.3 (#124)
* Build SQLite 3.50.3

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

* Update CI image for tests
2025-07-30 23:26:22 +02:00
108 changed files with 12519 additions and 5488 deletions

View File

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

View File

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

View File

@@ -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 and pivot tables based on the result sets
- run SQL queries against a SQLite database and create [Plotly][11] charts, graphs 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,7 +33,9 @@ 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].
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].
[1]: https://github.com/plotly/falcon
[2]: https://github.com/getredash/redash
@@ -47,3 +49,5 @@ It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4]
[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

@@ -4,7 +4,7 @@
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@*": ["./src/*"]
"@\/*": ["./src/*"]
}
}
}

View File

@@ -92,11 +92,23 @@ module.exports = function (config) {
'dom.w3c_touch_events.enabled': 1,
'dom.events.asyncClipboard.clipboardItem': true
}
},
ChromiumHeadlessWebGL: {
base: 'ChromiumHeadless',
flags: [
'--headless=new',
'--use-angle=swiftshader',
'--use-gl=angle',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--disable-gpu-sandbox',
'--no-sandbox'
]
}
},
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['ChromiumHeadless', 'FirefoxHeadlessTouch'],
browsers: ['ChromiumHeadlessWebGL', 'FirefoxHeadlessTouch'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

6745
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sqliteviz",
"version": "0.26.0",
"version": "0.29.0",
"license": "Apache-2.0",
"private": true,
"type": "module",
@@ -13,11 +13,15 @@
"format": "prettier . --write"
},
"dependencies": {
"@sigma/export-image": "^3.0.0",
"buffer": "^6.0.3",
"codemirror": "^5.65.18",
"codemirror-editor-vue3": "^2.8.0",
"core-js": "^3.6.5",
"dataurl-to-blob": "^0.0.1",
"graphology": "^0.26.0",
"graphology-layout": "^0.6.1",
"graphology-layout-forceatlas2": "^0.10.1",
"html2canvas": "^1.1.4",
"jquery": "^3.6.0",
"nanoid": "^3.1.12",
@@ -28,8 +32,11 @@
"react": "^16.14.0",
"react-chart-editor": "^0.46.1",
"react-dom": "^16.14.0",
"seedrandom": "^3.0.5",
"sigma": "^3.0.1",
"sql.js": "file:./lib/sql-js",
"tiny-emitter": "^2.1.0",
"tinycolor2": "^1.4.2",
"veaury": "^2.5.1",
"vue": "^3.5.11",
"vue-final-modal": "^4.5.5",

View File

@@ -1,16 +1,13 @@
<template>
<div id="app">
<router-view />
<modals-container />
</div>
</template>
<script>
import storedInquiries from '@/lib/storedInquiries'
import { ModalsContainer } from 'vue-final-modal'
export default {
components: { ModalsContainer },
computed: {
inquiries() {
return this.$store.state.inquiries
@@ -26,53 +23,16 @@ 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,3 +4,10 @@
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

@@ -0,0 +1,45 @@
@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

@@ -27,7 +27,7 @@
<script>
import CloseIcon from '@/components/svg/close'
import { version } from '../../../package.json'
import { version } from '../../package.json'
export default {
name: 'AppDiagnosticInfo',

View File

@@ -1,6 +1,6 @@
<template>
<div ref="chartContainer" class="chart-container">
<div v-show="!dataSources" class="warning chart-warning">
<div v-show="!dataSources" class="warning data-view-warning">
There is no data to build a chart. Run your SQL query and make sure the
result is not empty.
</div>
@@ -20,6 +20,7 @@
:useResizeHandler="useResizeHandler"
:debug="true"
:advancedTraceTypeSelector="true"
:hideControls="!showViewSettings"
@update="update"
@render="onRender"
/>
@@ -45,11 +46,17 @@ export default {
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean,
forPivot: Boolean
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
forPivot: Boolean,
showViewSettings: Boolean
},
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
emits: [
'update:exportToSvgEnabled',
'update:exportToHtmlEnabled',
'update',
'loadingImageCompleted'
],
data() {
return {
plotly,
@@ -80,6 +87,9 @@ export default {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
},
showViewSettings() {
this.handleResize()
}
},
created() {
@@ -102,15 +112,16 @@ export default {
},
{ deep: true }
)
this.$emit('update:importToSvgEnabled', true)
this.$emit('update:exportToSvgEnabled', true)
this.$emit('update:exportToHtmlEnabled', true)
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.chartContainer)
if (this.dataSources) {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
this.handleResize()
},
activated() {
this.useResizeHandler = true
@@ -123,6 +134,10 @@ 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() {
@@ -161,11 +176,8 @@ export default {
'text/html'
)
},
async prepareCopy(type = 'png') {
return await chartHelper.getImageDataUrl(
this.$refs.plotlyEditor.$el,
type
)
prepareCopy(type = 'png') {
return chartHelper.getImageDataUrl(this.$refs.plotlyEditor.$el, type)
}
}
}
@@ -176,13 +188,6 @@ 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

@@ -23,7 +23,7 @@
<script>
import tooltipMixin from '@/tooltipMixin'
import LoadingIndicator from '@/components/LoadingIndicator'
import LoadingIndicator from '@/components/Common/LoadingIndicator'
export default {
name: 'SideBarButton',

View File

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

View File

@@ -18,7 +18,7 @@
</template>
<script>
import LoadingIndicator from '@/components/LoadingIndicator'
import LoadingIndicator from '@/components/Common/LoadingIndicator'
export default {
name: 'Logs',

View File

@@ -13,16 +13,16 @@
:style="movableSplitterStyle"
/>
<div
v-show="!before.hidden"
ref="left"
class="splitpanes-pane"
:size="paneBefore.size"
max-size="30"
:style="styles.before"
>
<slot name="left-pane" />
</div>
<!-- Splitter start-->
<div
v-show="!before.hidden && !after.hidden"
class="splitpanes-splitter"
@mousedown="bindEvents"
@touchstart="bindEvents"
@@ -64,7 +64,12 @@
</div>
</div>
<!-- splitter end -->
<div ref="right" class="splitpanes-pane" :style="styles.after">
<div
v-show="!after.hidden"
ref="right"
class="splitpanes-pane"
:style="styles.after"
>
<slot name="right-pane" />
</div>
</div>
@@ -114,10 +119,12 @@ export default {
styles() {
return {
before: {
[this.horizontal ? 'height' : 'width']: `${this.paneBefore.size}%`
[this.horizontal ? 'height' : 'width']:
`${this.after.hidden ? 100 : this.paneBefore.size}%`
},
after: {
[this.horizontal ? 'height' : 'width']: `${this.paneAfter.size}%`
[this.horizontal ? 'height' : 'width']:
`${this.before.hidden ? 100 : this.paneAfter.size}%`
}
}
},

View File

@@ -102,11 +102,11 @@
<script>
import csv from '@/lib/csv'
import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import TextField from '@/components/Common/TextField'
import DelimiterSelector from './DelimiterSelector'
import CheckBox from '@/components/CheckBox'
import CheckBox from '@/components/Common/CheckBox'
import SqlTable from '@/components/SqlTable'
import Logs from '@/components/Logs'
import Logs from '@/components/Common/Logs'
import time from '@/lib/utils/time'
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'

View File

@@ -4,16 +4,21 @@
<component
:is="mode"
ref="viewComponent"
v-model:importToPngEnabled="importToPngEnabled"
v-model:importToSvgEnabled="importToSvgEnabled"
:initOptions="mode === initMode ? initOptions : undefined"
v-model:exportToPngEnabled="exportToPngEnabled"
v-model:exportToSvgEnabled="exportToSvgEnabled"
v-model:exportToHtmlEnabled="exportToHtmlEnabled"
v-model:exportToClipboardEnabled="exportToClipboardEnabled"
:initOptions="initOptionsByMode[mode]"
:data-sources="dataSource"
:showViewSettings="showViewSettings"
:showValueViewer="viewValuePanelVisible"
@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"
@@ -30,11 +35,44 @@
>
<pivot-icon />
</icon-button>
<icon-button
ref="graphBtn"
:active="mode === 'graph'"
tooltip="Switch to graph"
tooltipPosition="top-left"
@click="mode = 'graph'"
>
<graph-icon />
</icon-button>
<div class="side-tool-bar-divider" />
<icon-button
:disabled="!importToPngEnabled || loadingImage"
ref="settingsBtn"
:active="showViewSettings"
tooltip="Toggle visualisation settings visibility"
tooltipPosition="top-left"
@click="showViewSettings = !showViewSettings"
>
<settings-icon />
</icon-button>
<icon-button
v-if="mode === 'graph'"
ref="viewNodeOrEdgeBtn"
tooltip="View node or edge details"
tooltipPosition="top-left"
:active="viewValuePanelVisible"
@click="viewValuePanelVisible = !viewValuePanelVisible"
>
<view-cell-value-icon />
</icon-button>
<div class="side-tool-bar-divider" />
<icon-button
ref="pngExportBtn"
:disabled="!exportToPngEnabled || loadingImage"
:loading="loadingImage"
tooltip="Save as PNG image"
tooltipPosition="top-left"
@@ -44,7 +82,7 @@
</icon-button>
<icon-button
ref="svgExportBtn"
:disabled="!importToSvgEnabled"
:disabled="!exportToSvgEnabled"
tooltip="Save as SVG"
tooltipPosition="top-left"
@click="saveAsSvg"
@@ -54,6 +92,7 @@
<icon-button
ref="htmlExportBtn"
:disabled="!exportToHtmlEnabled"
tooltip="Save as HTML"
tooltipPosition="top-left"
@click="saveAsHtml"
@@ -62,6 +101,7 @@
</icon-button>
<icon-button
ref="copyToClipboardBtn"
:disabled="!exportToClipboardEnabled"
:loading="copyingImage"
tooltip="Copy visualisation to clipboard"
tooltipPosition="top-left"
@@ -72,10 +112,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"
@@ -85,18 +125,22 @@
</template>
<script>
import Chart from './Chart'
import Pivot from './Pivot'
import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton'
import Chart from '@/components/Chart.vue'
import Pivot from '@/components/Pivot'
import Graph from '@/components/Graph/index.vue'
import SideToolBar from '@/components/SideToolBar'
import IconButton from '@/components/Common/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 ViewCellValueIcon from '@/components/svg/viewCellValue'
import cIo from '@/lib/utils/clipboardIo'
import loadingDialog from '@/components/LoadingDialog'
import loadingDialog from '@/components/Common/LoadingDialog.vue'
import time from '@/lib/utils/time'
import events from '@/lib/utils/events'
@@ -105,11 +149,15 @@ export default {
components: {
Chart,
Pivot,
Graph,
SideToolBar,
IconButton,
ChartIcon,
PivotIcon,
GraphIcon,
SettingsIcon,
ExportToSvgIcon,
ViewCellValueIcon,
PngIcon,
HtmlIcon,
ClipboardIcon,
@@ -124,12 +172,22 @@ export default {
data() {
return {
mode: this.initMode || 'chart',
importToPngEnabled: true,
importToSvgEnabled: true,
exportToPngEnabled: true,
exportToSvgEnabled: true,
exportToHtmlEnabled: true,
exportToClipboardEnabled: true,
loadingImage: false,
copyingImage: false,
preparingCopy: false,
dataToCopy: null
dataToCopy: null,
initOptionsByMode: {
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,
viewValuePanelVisible: false
}
},
computed: {
@@ -138,9 +196,11 @@ export default {
}
},
watch: {
mode() {
mode(newMode, oldMode) {
this.$emit('update')
this.importToPngEnabled = true
this.exportToPngEnabled = true
this.exportToClipboardEnabled = true
this.initOptionsByMode[oldMode] = this.getOptionsForSave()
}
},
methods: {
@@ -170,14 +230,13 @@ export default {
async prepareCopy() {
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.$modal.show('prepareCopy')
this.showLoadingDialog = true
const t0 = performance.now()
await time.sleep(0)
this.dataToCopy = await this.$refs.viewComponent.prepareCopy()
const t1 = performance.now()
if (t1 - t0 < 950) {
this.$modal.hide('prepareCopy')
this.copyToClipboard()
} else {
this.preparingCopy = false
@@ -190,14 +249,13 @@ export default {
)
}
},
async copyToClipboard() {
copyToClipboard() {
cIo.copyImage(this.dataToCopy)
this.$modal.hide('prepareCopy')
this.showLoadingDialog = false
this.exportSignal('clipboard')
},
cancelCopy() {
this.dataToCopy = null
this.$modal.hide('prepareCopy')
},
saveAsSvg() {
@@ -218,7 +276,9 @@ export default {
events.send(
this.mode === 'chart' || this.plotlyInPivot
? 'viz_plotly.export'
: 'viz_pivot.export',
: this.mode === 'graph'
? 'viz_graph.export'
: 'viz_pivot.export',
null,
eventLabels
)

View File

@@ -0,0 +1,141 @@
<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"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.adjustSizes"
@option-change="update('adjustSizes', $event)"
/>
</Field>
<Field
label="Barnes-Hut optimize"
fieldContainerClassName="test_fa2_barnes_hut"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.barnesHutOptimize"
@option-change="update('barnesHutOptimize', $event)"
/>
</Field>
<Field
v-show="modelValue.barnesHutOptimize"
label="Barnes-Hut Theta"
fieldContainerClassName="test_fa2_barnes_theta"
>
<NumericInput
:value="modelValue.barnesHutTheta"
@update="update('barnesHutTheta', $event)"
/>
</Field>
<Field
label="Strong gravity mode"
fieldContainerClassName="test_fa2_strong_gravity"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.strongGravityMode"
@option-change="update('strongGravityMode', $event)"
/>
</Field>
<Field
label="Noack's LinLog model"
fieldContainerClassName="test_fa2_lin_log"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.linLogMode"
@option-change="update('linLogMode', $event)"
/>
</Field>
<Field
label="Outbound attraction distribution"
fieldContainerClassName="test_fa2_outbound_attraction"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.outboundAttractionDistribution"
@option-change="update('outboundAttractionDistribution', $event)"
/>
</Field>
<Field label="Slow down" fieldContainerClassName="test_fa2_slow_down">
<NumericInput
:value="modelValue.slowDown"
:min="0"
@update="update('slowDown', $event)"
/>
</Field>
<Field label="Edge weight">
<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"
>
<NumericInput
:value="modelValue.edgeWeightInfluence"
@update="update('edgeWeightInfluence', $event)"
/>
</Field>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
booleanOptions: markRaw([
{ label: 'Yes', value: true },
{ label: 'No', value: false }
])
}
},
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

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

View File

@@ -0,0 +1,148 @@
<template>
<Field label="Color" fieldContainerClassName="test_edge_color">
<RadioBlocks
:options="edgeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field
v-if="modelValue.type === 'constant'"
fieldContainerClassName="test_edge_color_value"
>
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field fieldContainerClassName="test_edge_color_value">
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
</Field>
<Field fieldContainerClassName="test_edge_color_mapping_mode">
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="modelValue.sourceUsage"
@option-change="updateSettings('sourceUsage', $event)"
/>
</Field>
<Field v-if="modelValue.sourceUsage === 'map_to'">
<ColorscalePicker
:selected="modelValue.colorscale"
className="colorscale-picker"
@colorscale-change="updateSettings('colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field
v-if="modelValue.type !== 'constant' && modelValue.sourceUsage === 'map_to'"
label="Color as"
fieldContainerClassName="test_edge_color_as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field
v-if="modelValue.type !== 'constant' && modelValue.sourceUsage === 'map_to'"
label="Colorscale direction"
fieldContainerClassName="test_edge_color_colorscale_direction"
>
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="modelValue.colorscaleDirection"
@option-change="updateSettings('colorscaleDirection', $event)"
/>
</Field>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import ColorscalePicker from 'react-chart-editor/lib/components/widgets/ColorscalePicker'
import ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
import Field from 'react-chart-editor/lib/components/fields/Field'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Dropdown: applyPureReactInVue(Dropdown),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
ColorPicker: applyPureReactInVue(ColorPicker)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
edgeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
сolorAsOptions: markRaw([
{ label: 'Continious', value: 'continious' },
{ label: 'Categorical', value: 'categorical' }
]),
сolorscaleDirections: markRaw([
{ label: 'Normal', value: 'normal' },
{ label: 'Recersed', value: 'reversed' }
]),
colorSourceUsageOptions: markRaw([
{ label: 'Direct', value: 'direct' },
{ label: 'Map to', value: 'map_to' }
]),
defaultColorSettings: {
constant: { value: '#1F77B4' },
variable: {
source: null,
sourceUsage: 'map_to',
colorscale: null,
mode: 'categorical',
colorscaleDirection: 'normal'
}
}
}
},
methods: {
updateColorType(newColorType) {
const currentColorType = this.modelValue.type
this.defaultColorSettings[currentColorType] = this.modelValue
this.$emit('update:modelValue', {
type: newColorType,
...this.defaultColorSettings[newColorType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>
<style scoped>
:deep(.customPickerContainer) {
float: right;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<Field label="Size" fieldContainerClassName="test_edge_size">
<RadioBlocks
:options="edgeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field fieldContainerClassName="test_edge_size_value">
<NumericInput
v-if="modelValue.type === 'constant'"
:value="modelValue.value"
:min="1"
@update="updateSettings('value', $event)"
/>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
</Field>
</Field>
<template v-if="modelValue.type !== 'constant'">
<Field label="Size scale" fieldContainerClassName="test_edge_size_scale">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Minimum size" fieldContainerClassName="test_edge_size_min">
<NumericInput
:value="modelValue.min"
@update="updateSettings('min', $event)"
/>
</Field>
</template>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import Field from 'react-chart-editor/lib/components/fields/Field'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
edgeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
defaultSizeSettings: {
constant: { value: 4 },
variable: { source: null, scale: 1, min: 1 }
}
}
},
methods: {
updateSizeType(newSizeType) {
const currentSizeType = this.modelValue.type
this.defaultSizeSettings[currentSizeType] = this.modelValue
this.$emit('update:modelValue', {
type: newSizeType,
...this.defaultSizeSettings[newSizeType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<Field
label="Initial iterations"
fieldContainerClassName="test_fa2_iteration_amount"
>
<NumericInput
:value="modelValue.initialIterationsAmount"
:min="1"
@update="update('initialIterationsAmount', $event)"
/>
</Field>
<Field label="Gravity" fieldContainerClassName="test_fa2_gravity">
<NumericInput
:value="modelValue.gravity"
@update="update('gravity', $event)"
/>
</Field>
</template>
<script>
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
NumericInput: applyPureReactInVue(NumericInput)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -0,0 +1,812 @@
<template>
<div :class="['plotly_editor', { with_controls: showViewSettings }]">
<GraphEditorControls v-show="showViewSettings">
<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 ref="objectTypeField" 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>
<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>
<Panel group="Style" name="General">
<Fold name="General">
<Field label="Background color">
<ColorPicker
:selectedColor="settings.style.backgroundColor"
@color-change="settings.style.backgroundColor = $event"
/>
</Field>
<Field label="Highlight mode">
<Dropdown
:options="highlightModeOptions"
:value="settings.style.highlightMode"
className="test_highlight_mode_select"
@change="updateHighlightNodeMode"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Nodes">
<Fold name="Nodes">
<Field label="Label">
<Dropdown
:options="keysOptions"
:value="settings.style.nodes.label.source"
className="test_label_select"
@change="updateNodes('label.source', $event)"
/>
</Field>
<Field label="Label color">
<ColorPicker
:selectedColor="settings.style.nodes.label.color"
@color-change="updateNodes('label.color', $event)"
/>
</Field>
<NodeSizeSettings
v-model="settings.style.nodes.size"
:keyOptions="keysOptions"
@update:model-value="updateNodes('size', $event)"
/>
<NodeColorSettings
v-model="settings.style.nodes.color"
:keyOptions="keysOptions"
@update:model-value="updateNodes('color', $event)"
/>
</Fold>
</Panel>
<Panel group="Style" name="Edges">
<Fold name="Edges">
<Field
label="Direction"
fieldContainerClassName="test_edge_direction"
>
<RadioBlocks
:options="visibilityOptions"
:activeOption="settings.style.edges.showDirection"
@option-change="updateEdges('showDirection', $event)"
/>
</Field>
<Field label="Label">
<Dropdown
:options="keysOptions"
:value="settings.style.edges.label.source"
className="test_edge_label_select"
@change="updateEdges('label.source', $event)"
/>
</Field>
<Field label="Label color">
<ColorPicker
:selectedColor="settings.style.edges.label.color"
@color-change="updateEdges('label.color', $event)"
/>
</Field>
<EdgeSizeSettings
v-model="settings.style.edges.size"
:keyOptions="keysOptions"
@update:model-value="updateEdges('size', $event)"
/>
<EdgeColorSettings
v-model="settings.style.edges.color"
:keyOptions="keysOptions"
@update:model-value="updateEdges('color', $event)"
/>
</Fold>
</Panel>
<Panel group="Style" name="Layout">
<Fold name="Layout">
<Field label="Algorithm">
<Dropdown
:options="layoutOptions"
:value="settings.layout.type"
:clearable="false"
className="test_layout_algorithm_select"
@change="updateLayout($event)"
/>
</Field>
<component
:is="layoutSettingsComponentMap[settings.layout.type]"
v-if="settings.layout.type !== 'circular'"
v-model="settings.layout.options"
:keyOptions="keysOptions"
@update:model-value="updateLayout(settings.layout.type)"
/>
</Fold>
<template v-if="settings.layout.type === 'forceAtlas2'">
<Fold name="Advanced layout settings">
<AdvancedForceAtlasLayoutSettings
v-model="settings.layout.options"
:keyOptions="keysOptions"
@update:model-value="updateLayout(settings.layout.type)"
/>
</Fold>
<div class="force-atlas-buttons">
<Button
variant="secondary"
class="test_fa2_reset"
@click="resetFA2LayoutSettings"
>
Reset
</Button>
<Button
variant="primary"
class="test_fa2_toggle"
@click="toggleFA2Layout"
>
<template #node:icon>
<div
:style="{
padding: '0 3px'
}"
>
<RunIcon v-if="!fa2Running" />
<StopIcon v-else />
</div>
</template>
{{ fa2Running ? 'Stop' : 'Start' }}
</Button>
</div>
</template>
</Panel>
</PanelMenuWrapper>
</GraphEditorControls>
<div
ref="graph"
class="test_graph_output"
:style="{
height: '100%',
width: '100%',
backgroundColor: settings.style.backgroundColor
}"
/>
</div>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import GraphEditorControls from '@/lib/GraphEditorControls.jsx'
import { PanelMenuWrapper, Panel, Fold, Section } from 'react-chart-editor'
import 'react-chart-editor/lib/react-chart-editor.css'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
import Button from 'react-chart-editor/lib/components/widgets/Button'
import Field from 'react-chart-editor/lib/components/fields/Field'
import RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue'
import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSettings.vue'
// eslint-disable-next-line max-len
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 RunIcon from '@/components/svg/run.vue'
import StopIcon from '@/components/svg/stop.vue'
import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image'
import {
buildNodes,
buildEdges,
updateNodes,
updateEdges,
reduceNodes,
reduceEdges
} from '@/lib/graphHelper'
import Graph from 'graphology'
import { circular, random, circlepack } from 'graphology-layout'
import Sigma from 'sigma'
import seedrandom from 'seedrandom'
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: {
GraphEditorControls: applyPureReactInVue(GraphEditorControls),
PanelMenuWrapper: applyPureReactInVue(PanelMenuWrapper),
Panel: applyPureReactInVue(Panel),
PanelSection: applyPureReactInVue(Section),
Dropdown: applyPureReactInVue(Dropdown),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
Fold: applyPureReactInVue(Fold),
Button: applyPureReactInVue(Button),
ColorPicker: applyPureReactInVue(ColorPicker),
RunIcon,
StopIcon,
RandomLayoutSettings,
CirclePackLayoutSettings,
NodeColorSettings,
NodeSizeSettings,
EdgeSizeSettings,
EdgeColorSettings,
AdvancedForceAtlasLayoutSettings
},
inject: ['tabLayout'],
props: {
dataSources: Object,
initOptions: Object,
showViewSettings: Boolean
},
emits: ['update', 'selectItem', 'clearSelection'],
data() {
return {
graph: new Graph({ multi: true, allowSelfLoops: true }),
renderer: null,
fa2Layout: null,
fa2Running: false,
checkIteration: null,
visibilityOptions: markRaw([
{ label: 'Show', value: true },
{ label: 'Hide', value: false }
]),
layoutOptions: markRaw([
{ label: 'Circular', value: 'circular' },
{ label: 'Random', value: 'random' },
{ label: 'Circle pack', value: 'circlepack' },
{ label: 'ForceAtlas2', value: 'forceAtlas2' }
]),
layoutSettingsComponentMap: markRaw({
random: RandomLayoutSettings,
circlepack: CirclePackLayoutSettings,
forceAtlas2: ForceAtlasLayoutSettings
}),
selectedNodeId: undefined,
hoveredNodeId: undefined,
selectedEdgeId: undefined,
hoveredEdgeId: undefined,
settings: this.initOptions
? JSON.parse(JSON.stringify(this.initOptions))
: {
structure: {
nodeId: null,
objectType: null,
edgeSource: null,
edgeTarget: null
},
style: {
backgroundColor: 'white',
highlightMode: 'node_and_neighbors',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4',
opacity: 100
},
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
}
},
layoutOptionsArchive: {
random: null,
circlepack: null,
forceAtlas2: null
},
highlightModeOptions: markRaw([
{ label: 'Node alone', value: 'node_alone' },
{ label: 'Node and neighbors', value: 'node_and_neighbors' },
{
label: 'Include edges between neighbors',
value: 'include_neighbor_edges'
}
])
}
},
computed: {
records() {
if (!this.dataSources) {
return []
}
const firstColumnName = Object.keys(this.dataSources)[0]
try {
return (
this.dataSources[firstColumnName].map(json => JSON.parse(json)) || []
)
} catch {
return []
}
},
keysOptions() {
if (!this.dataSources) {
return []
}
const keySet = this.records.reduce((result, currentRecord) => {
Object.keys(currentRecord).forEach(key => result.add(key))
return result
}, new Set())
return Array.from(keySet)
},
neighborsOfSelectedNode() {
if (this.settings.style.highlightMode === 'node_alone') {
return undefined
}
return this.selectedNodeId
? new Set(this.graph.neighbors(this.selectedNodeId))
: undefined
},
neighborsOfHoveredNode() {
if (this.settings.style.highlightMode === 'node_alone') {
return undefined
}
return this.hoveredNodeId
? new Set(this.graph.neighbors(this.hoveredNodeId))
: undefined
},
hoveredEdgeExtremities() {
return this.hoveredEdgeId
? this.graph.extremities(this.hoveredEdgeId)
: []
},
selectedEdgeExtremities() {
return this.selectedEdgeId
? this.graph.extremities(this.selectedEdgeId)
: []
},
interactionState() {
return {
selectedNodeId: this.selectedNodeId,
hoveredNodeId: this.hoveredNodeId,
selectedEdgeId: this.selectedEdgeId,
hoveredEdgeId: this.hoveredEdgeId,
neighborsOfSelectedNode: this.neighborsOfSelectedNode,
neighborsOfHoveredNode: this.neighborsOfHoveredNode,
hoveredEdgeExtremities: this.hoveredEdgeExtremities,
selectedEdgeExtremities: this.selectedEdgeExtremities
}
}
},
watch: {
dataSources() {
if (this.dataSources) {
this.buildGraph()
}
},
settings: {
deep: true,
handler() {
this.$emit('update')
}
},
'settings.structure': {
deep: true,
handler() {
this.buildGraph()
}
},
'settings.layout.type': {
immediate: true,
handler() {
events.send('viz_graph.render', null, {
layout: this.settings.layout.type
})
}
},
tabLayout: {
deep: true,
handler() {
if (this.tabLayout.dataView !== 'hidden' && this.renderer) {
this.renderer.scheduleRender()
}
}
}
},
mounted() {
if (this.dataSources) {
this.buildGraph()
}
},
methods: {
buildGraph() {
this.clearSelection()
if (this.renderer) {
this.renderer.kill()
}
this.graph.clear()
buildNodes(this.graph, this.dataSources, this.settings)
buildEdges(this.graph, this.dataSources, this.settings)
// Apply visual settings
updateNodes(this.graph, this.settings.style.nodes)
updateEdges(this.graph, this.settings.style.edges)
this.updateLayout(this.settings.layout.type)
this.renderer = new Sigma(this.graph, this.$refs.graph, {
renderEdgeLabels: true,
allowInvalidContainer: true,
labelColor: { attribute: 'labelColor', color: '#444444' },
edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' },
enableEdgeEvents: true,
zIndex: true,
nodeReducer: (node, data) =>
reduceNodes(node, data, this.interactionState, this.settings),
edgeReducer: (edge, data) =>
reduceEdges(
edge,
data,
this.interactionState,
this.settings,
this.graph
)
})
this.renderer.on('clickNode', ({ node }) => {
this.selectedNodeId = node
this.selectedEdgeId = undefined
this.$emit('selectItem', this.graph.getNodeAttributes(node).data)
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('clickEdge', ({ edge }) => {
this.selectedEdgeId = edge
this.selectedNodeId = undefined
this.$emit('selectItem', this.graph.getEdgeAttributes(edge).data)
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('clickStage', () => {
this.clearSelection()
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('enterNode', ({ node }) => {
this.hoveredNodeId = node
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('enterEdge', ({ edge }) => {
this.hoveredEdgeId = edge
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('leaveNode', () => {
this.hoveredNodeId = undefined
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('leaveEdge', () => {
this.hoveredEdgeId = undefined
this.renderer.refresh({
skipIndexation: true
})
})
if (this.settings.layout.type === 'forceAtlas2') {
this.autoRunFA2Layout()
}
},
clearSelection() {
this.selectedNodeId = undefined
this.selectedEdgeId = undefined
this.$emit('clearSelection')
},
updateHighlightNodeMode(mode) {
this.settings.style.highlightMode = mode
if (this.renderer) {
this.renderer.refresh({
skipIndexation: true
})
}
},
updateStructure(attributeName, value) {
this.settings.structure[attributeName] = value
},
updateNodes(attributeName, value) {
const attributePath = attributeName.split('.')
attributePath.reduce((result, current, index) => {
if (index === attributePath.length - 1) {
return (result[current] = value)
} else {
return result[current]
}
}, this.settings.style.nodes)
updateNodes(this.graph, {
[attributePath[0]]: this.settings.style.nodes[attributePath[0]]
})
},
updateEdges(attributeName, value) {
const attributePath = attributeName.split('.')
attributePath.reduce((result, current, index) => {
if (index === attributePath.length - 1) {
return (result[current] = value)
} else {
return result[current]
}
}, this.settings.style.edges)
updateEdges(this.graph, {
[attributePath[0]]: this.settings.style.edges[attributePath[0]]
})
},
updateLayout(layoutType) {
const prevLayout = this.settings.layout.type
// Change layout type? - restore layout settings or set default settings
if (layoutType !== prevLayout) {
this.layoutOptionsArchive[prevLayout] = this.settings.layout.options
this.settings.layout.options = this.layoutOptionsArchive[layoutType]
if (!this.settings.layout.options) {
if (layoutType === 'forceAtlas2') {
this.setRecommendedFA2Settings()
} else if (['random', 'circlepack'].includes(layoutType)) {
this.settings.layout.options = {
seedValue: 1
}
}
}
this.settings.layout.type = layoutType
}
// In any case kill FA2 if it exists
if (this.fa2Layout) {
if (this.fa2Layout.isRunning()) {
this.stopFA2Layout()
}
this.fa2Layout.kill()
}
if (layoutType === 'circular') {
circular.assign(this.graph)
return
}
if (layoutType === 'random') {
random.assign(this.graph, {
rng: seedrandom(this.settings.layout.options.seedValue)
})
return
}
if (layoutType === 'circlepack') {
this.graph.forEachNode(nodeId => {
this.graph.updateNode(nodeId, attributes => {
const newAttributes = { ...attributes }
// Delete old hierarchy attributes
Object.keys(newAttributes)
.filter(key => key.startsWith('hierarchyAttribute'))
.forEach(
hierarchyAttributeKey =>
delete newAttributes[hierarchyAttributeKey]
)
// Set new hierarchy attributes
this.settings.layout.options.hierarchyAttributes?.forEach(
(hierarchyAttribute, index) => {
newAttributes['hierarchyAttribute' + index] =
attributes.data[hierarchyAttribute]
}
)
return newAttributes
})
})
circlepack.assign(this.graph, {
hierarchyAttributes:
this.settings.layout.options.hierarchyAttributes?.map(
(_, index) => 'hierarchyAttribute' + index
) || [],
rng: seedrandom(this.settings.layout.options.seedValue)
})
return
}
if (layoutType === 'forceAtlas2') {
if (
!this.graph.someNode(
(nodeKey, attributes) =>
typeof attributes.x === 'number' &&
typeof attributes.y === 'number'
)
) {
circular.assign(this.graph)
}
this.fa2Layout = markRaw(
new FA2Layout(this.graph, {
getEdgeWeight: (_, attr) =>
this.settings.layout.options.weightSource
? attr.data[this.settings.layout.options.weightSource]
: 1,
settings: this.settings.layout.options
})
)
if (layoutType !== prevLayout) {
this.autoRunFA2Layout()
}
}
},
toggleFA2Layout() {
if (this.fa2Layout.isRunning()) {
this.stopFA2Layout()
} else {
this.fa2Running = true
this.fa2Layout.start()
}
},
stopFA2Layout() {
this.fa2Running = false
this.fa2Layout.stop()
if (this.checkIteration) {
this.fa2Layout.worker.removeEventListener(
'message',
this.checkIteration
)
this.checkIteration = null
}
},
autoRunFA2Layout() {
let iteration = 1
this.checkIteration = () => {
if (
iteration === this.settings.layout.options.initialIterationsAmount
) {
this.stopFA2Layout()
}
iteration++
}
this.fa2Layout.worker.addEventListener('message', this.checkIteration)
this.fa2Running = true
this.fa2Layout.start()
},
setRecommendedFA2Settings() {
const sensibleSettings = forceAtlas2.default.inferSettings(this.graph)
this.settings.layout.options = {
initialIterationsAmount: 50,
adjustSizes: false,
barnesHutOptimize: false,
barnesHutTheta: 0.5,
edgeWeightInfluence: 0,
gravity: 1,
linLogMode: false,
outboundAttractionDistribution: false,
scalingRatio: 1,
slowDown: 1,
strongGravityMode: false,
...sensibleSettings
}
if (
[Infinity, -Infinity].includes(this.settings.layout.options.slowDown)
) {
this.settings.layout.options.slowDown = 1
}
},
resetFA2LayoutSettings() {
if (this.initOptions?.layout.type === 'forceAtlas2') {
this.settings.layout = JSON.parse(
JSON.stringify(this.initOptions.layout)
)
} else {
this.setRecommendedFA2Settings()
}
this.updateLayout(this.settings.layout.type)
},
saveAsPng() {
return downloadAsPNG(this.renderer, {
backgroundColor: this.settings.style.backgroundColor
})
},
prepareCopy() {
return drawOnCanvas(this.renderer, {
backgroundColor: this.settings.style.backgroundColor
})
}
}
}
</script>
<style scoped>
.plotly_editor.with_controls > div {
display: flex !important;
}
:deep(.customPickerContainer) {
float: right;
}
.force-atlas-buttons {
display: flex;
width: 100%;
gap: 16px;
}
.force-atlas-buttons :deep(button) {
flex-grow: 1;
flex-basis: 0;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<Field label="Color" fieldContainerClassName="test_node_color">
<RadioBlocks
:options="nodeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field
v-if="modelValue.type === 'constant'"
fieldContainerClassName="test_node_color_value"
>
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field fieldContainerClassName="test_node_color_value">
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
<Dropdown
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"
>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="modelValue.sourceUsage"
@option-change="updateSettings('sourceUsage', $event)"
/>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' ||
modelValue.type === 'calculated'
"
>
<ColorscalePicker
:selected="modelValue.colorscale"
className="colorscale-picker"
@colorscale-change="updateSettings('colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field label="Opacity" fieldContainerClassName="test_node_opacity">
<NumericInput
:value="modelValue.opacity"
:showSlider="true"
:integerOnly="true"
:max="100"
:min="0"
units="%"
@update="updateSettings('opacity', $event)"
/>
</Field>
<Field
v-if="modelValue.type === 'map_to' || modelValue.type === 'calculated'"
label="Color as"
fieldContainerClassName="test_node_color_as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' || modelValue.type === 'calculated'
"
label="Colorscale direction"
fieldContainerClassName="test_node_color_colorscale_direction"
>
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="modelValue.colorscaleDirection"
@option-change="updateSettings('colorscaleDirection', $event)"
/>
</Field>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import ColorscalePicker from 'react-chart-editor/lib/components/widgets/ColorscalePicker'
import ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
import Field from 'react-chart-editor/lib/components/fields/Field'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
NumericInput: applyPureReactInVue(NumericInput),
Dropdown: applyPureReactInVue(Dropdown),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
ColorPicker: applyPureReactInVue(ColorPicker)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
nodeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
nodeCalculatedColorMethodOptions: markRaw([
{ label: 'Degree', value: 'degree' },
{ label: 'In degree', value: 'inDegree' },
{ label: 'Out degree', value: 'outDegree' }
]),
сolorAsOptions: markRaw([
{ label: 'Continious', value: 'continious' },
{ label: 'Categorical', value: 'categorical' }
]),
сolorscaleDirections: markRaw([
{ label: 'Normal', value: 'normal' },
{ label: 'Recersed', value: 'reversed' }
]),
colorSourceUsageOptions: markRaw([
{ label: 'Direct', value: 'direct' },
{ label: 'Map to', value: 'map_to' }
]),
defaultColorSettings: {
constant: { value: '#1F77B4', opacity: 100 },
variable: {
source: null,
sourceUsage: 'map_to',
colorscale: null,
mode: 'categorical',
colorscaleDirection: 'normal',
opacity: 100
},
calculated: {
method: 'degree',
colorscale: null,
mode: 'continious',
colorscaleDirection: 'normal',
opacity: 100
}
}
}
},
methods: {
updateColorType(newColorType) {
const currentColorType = this.modelValue.type
this.defaultColorSettings[currentColorType] = this.modelValue
this.$emit('update:modelValue', {
type: newColorType,
...this.defaultColorSettings[newColorType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>
<style scoped>
:deep(.customPickerContainer) {
float: right;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<Field label="Size" fieldContainerClassName="test_node_size">
<RadioBlocks
:options="nodeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field fieldContainerClassName="test_node_size_value">
<NumericInput
v-if="modelValue.type === 'constant'"
:value="modelValue.value"
:min="1"
class="test_node_size_value"
@update="updateSettings('value', $event)"
/>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
<Dropdown
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">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Size mode" fieldContainerClassName="test_node_size_mode">
<RadioBlocks
:options="nodeSizeModeOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field label="Minimum size" fieldContainerClassName="test_node_size_min">
<NumericInput
:value="modelValue.min"
@update="updateSettings('min', $event)"
/>
</Field>
</template>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import Field from 'react-chart-editor/lib/components/fields/Field'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
nodeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
nodeCalculatedSizeMethodOptions: markRaw([
{ label: 'Degree', value: 'degree' },
{ label: 'In degree', value: 'inDegree' },
{ label: 'Out degree', value: 'outDegree' }
]),
nodeSizeModeOptions: markRaw([
{ label: 'Area', value: 'area' },
{ label: 'Diameter', value: 'diameter' }
]),
defaultSizeSettings: {
constant: { value: 4 },
variable: { source: null, scale: 1, mode: 'diameter', min: 1 },
calculated: { method: 'degree', scale: 1, mode: 'diameter', min: 1 }
}
}
},
methods: {
updateSizeType(newSizeType) {
const currentSizeType = this.modelValue.type
this.defaultSizeSettings[currentSizeType] = this.modelValue
this.$emit('update:modelValue', {
type: newSizeType,
...this.defaultSizeSettings[newSizeType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

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

View File

@@ -0,0 +1,146 @@
<template>
<div ref="graphContainer" class="graph-container">
<div v-show="!dataSources" class="warning data-view-warning no-data">
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>
<splitpanes
:before="{ size: 70, max: 100 }"
:after="{ size: 30, max: 50, hidden: !showValueViewer }"
:default="{ before: 70, after: 30 }"
class="graph"
:style="{
height:
!dataSources || !dataSourceIsValid ? 'calc(100% - 40px)' : '100%'
}"
>
<template #left-pane>
<div ref="graphEditorContainer" :style="{ height: '100%' }">
<GraphEditor
ref="graphEditor"
:dataSources="dataSources"
:initOptions="initOptions"
:showViewSettings="showViewSettings"
@update="$emit('update')"
@select-item="selectedItem = $event"
@clear-selection="selectedItem = null"
/>
</div>
</template>
<template v-if="showValueViewer" #right-pane>
<value-viewer
:empty="!selectedItem"
emptyMessage="No node or edge selected to view"
:value="JSON.stringify(selectedItem)"
defaultFormat="json"
/>
</template>
</splitpanes>
</div>
</template>
<script>
import 'react-chart-editor/lib/react-chart-editor.css'
import GraphEditor from '@/components/Graph/GraphEditor.vue'
import { dataSourceIsValid } from '@/lib/graphHelper'
import ValueViewer from '@/components/ValueViewer.vue'
import Splitpanes from '@/components/Common/Splitpanes'
export default {
name: 'Graph',
components: { GraphEditor, ValueViewer, Splitpanes },
props: {
dataSources: Object,
initOptions: Object,
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
exportToHtmlEnabled: Boolean,
showViewSettings: Boolean,
showValueViewer: Boolean
},
emits: [
'update:exportToSvgEnabled',
'update:exportToHtmlEnabled',
'update:exportToPngEnabled',
'update:exportToClipboardEnabled',
'update',
'loadingImageCompleted'
],
data() {
return {
resizeObserver: null,
selectedItem: null
}
},
computed: {
dataSourceIsValid() {
return !this.dataSources || dataSourceIsValid(this.dataSources)
}
},
watch: {
async showViewSettings() {
await this.$nextTick()
this.handleResize()
},
dataSources() {
this.$emit('update:exportToPngEnabled', !!this.dataSources)
this.$emit('update:exportToClipboardEnabled', !!this.dataSources)
}
},
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)
this.resizeObserver.observe(this.$refs.graphEditorContainer)
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.graphEditorContainer)
},
methods: {
getOptionsForSave() {
return this.$refs.graphEditor.settings
},
async saveAsPng() {
await this.$refs.graphEditor.saveAsPng()
this.$emit('loadingImageCompleted')
},
prepareCopy() {
return this.$refs.graphEditor.prepareCopy()
},
async handleResize() {
const renderer = this.$refs.graphEditor?.renderer
if (renderer) {
renderer.refresh()
renderer.getCamera().animatedReset({ duration: 600 })
}
}
}
}
</script>
<style scoped>
.graph-container {
height: 100%;
}
.graph {
min-height: 242px;
}
:deep(.editor_controls .sidebar__item:before) {
width: 0;
}
</style>

View File

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

View File

@@ -0,0 +1,300 @@
<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>
<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>
</div>
</div>
</template>
<script>
import $ from 'jquery'
import Multiselect from 'vue-multiselect'
import PivotSortBtn from './PivotSortBtn'
import {
renderers,
aggregators,
zeroValAggregators,
twoValAggregators
} from '../pivotHelper'
export default {
name: 'PivotUi',
components: {
Multiselect,
PivotSortBtn
},
props: {
keyNames: Array,
modelValue: Object
},
emits: ['update:modelValue', 'update'],
data() {
const aggregatorName =
(this.modelValue && this.modelValue.aggregatorName) || 'Count'
const rendererName =
(this.modelValue && this.modelValue.rendererName) || 'Table'
return {
renderer: {
name: rendererName,
fun: $.pivotUtilities.renderers[rendererName]
},
aggregator: {
name: aggregatorName,
fun: $.pivotUtilities.aggregators[aggregatorName]
},
rows: (this.modelValue && this.modelValue.rows) || [],
cols: (this.modelValue && this.modelValue.cols) || [],
val1:
(this.modelValue && this.modelValue.vals && this.modelValue.vals[0]) ||
'',
val2:
(this.modelValue && this.modelValue.vals && this.modelValue.vals[1]) ||
'',
colOrder: (this.modelValue && this.modelValue.colOrder) || 'key_a_to_z',
rowOrder: (this.modelValue && this.modelValue.rowOrder) || 'key_a_to_z'
}
},
computed: {
valCount() {
if (zeroValAggregators.includes(this.aggregator.name)) {
return 0
}
if (twoValAggregators.includes(this.aggregator.name)) {
return 2
}
return 1
},
renderers() {
return renderers
},
aggregators() {
return aggregators
},
rowsToSelect() {
return this.keyNames.filter(key => !this.cols.includes(key))
},
colsToSelect() {
return this.keyNames.filter(key => !this.rows.includes(key))
}
},
watch: {
renderer() {
this.returnValue()
},
aggregator() {
this.returnValue()
},
rows() {
this.returnValue()
},
cols() {
this.returnValue()
},
val1() {
this.returnValue()
},
val2() {
this.returnValue()
},
colOrder() {
this.returnValue()
},
rowOrder() {
this.returnValue()
}
},
methods: {
returnValue() {
const vals = []
for (let i = 1; i <= this.valCount; i++) {
vals.push(this[`val${i}`])
}
this.$emit('update')
this.$emit('update:modelValue', {
rows: this.rows,
cols: this.cols,
colOrder: this.colOrder,
rowOrder: this.rowOrder,
aggregator: this.aggregator.fun(vals),
aggregatorName: this.aggregator.name,
renderer: this.renderer.fun,
rendererName: this.renderer.name,
vals
})
}
}
}
</script>
<style scoped>
.pivot-ui {
padding: 12px 24px;
color: var(--color-text-base);
font-size: 12px;
border-bottom: 1px solid var(--color-border-light);
background-color: var(--color-bg-light);
}
.pivot-ui .row {
display: flex;
align-items: center;
margin: 12px 0;
}
.pivot-ui .row label {
width: 76px;
flex-shrink: 0;
}
.pivot-ui .row .sqliteviz-select.short {
width: 220px;
flex-shrink: 0;
}
.pivot-ui .row .aggr-arg {
margin-left: 12px;
max-width: 220px;
}
.pivot-ui .row .sort-btn {
margin-left: 12px;
flex-shrink: 0;
}
.switcher {
display: block;
width: min-content;
white-space: nowrap;
margin: auto;
cursor: pointer;
}
.switcher:hover {
color: var(--color-accent);
}
</style>

View File

@@ -5,6 +5,7 @@
result is not empty.
</div>
<pivot-ui
v-show="showViewSettings"
v-model="pivotOptions"
:keyNames="columns"
@update="$emit('update')"
@@ -30,9 +31,9 @@ import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/pivot.css'
import PivotUi from './PivotUi'
import PivotUi from './PivotUi/index.vue'
import pivotHelper from './pivotHelper'
import Chart from '@/views/MainView/Workspace/Tabs/Tab/DataView/Chart'
import Chart from '@/components/Chart'
import chartHelper from '@/lib/chartHelper'
import events from '@/lib/utils/events'
import plotly from 'plotly.js'
@@ -46,14 +47,16 @@ export default {
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
showViewSettings: Boolean
},
emits: [
'loadingImageCompleted',
'update',
'update:importToSvgEnabled',
'update:importToPngEnabled'
'update:exportToSvgEnabled',
'update:exportToPngEnabled',
'update:exportToHtmlEnabled'
],
data() {
return {
@@ -110,11 +113,11 @@ export default {
immediate: true,
handler() {
this.$emit(
'update:importToPngEnabled',
'update:exportToPngEnabled',
this.pivotOptions.rendererName !== 'TSV Export'
)
this.$emit(
'update:importToSvgEnabled',
'update:exportToSvgEnabled',
this.viewStandartChart || this.viewCustomChart
)
events.send('viz_pivot.render', null, {
@@ -124,8 +127,14 @@ export default {
},
pivotOptions() {
this.show()
},
showViewSettings() {
this.handleResize()
}
},
created() {
this.$emit('update:exportToHtmlEnabled', true)
},
mounted() {
this.show()
// We need to detect resizing because plotly doesn't resize when resize its container
@@ -218,12 +227,12 @@ export default {
async prepareCopy() {
if (this.viewCustomChart) {
return await this.$refs.customChart.prepareCopy()
return this.$refs.customChart.prepareCopy()
}
if (this.viewStandartChart) {
return await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
return chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
}
return await pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
return pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
},
async saveAsSvg() {
@@ -317,4 +326,8 @@ export default {
.pivot-output:empty {
flex-grow: 0;
}
:deep(.js-plotly-plot) {
height: 100%;
}
</style>

View File

@@ -40,7 +40,7 @@
</template>
<script>
import IconButton from '@/components/IconButton'
import IconButton from '@/components/Common/IconButton'
import ArrowIcon from '@/components/svg/arrow'
import EdgeArrowIcon from '@/components/svg/edgeArrow'

View File

@@ -1,40 +1,66 @@
<template>
<div ref="runResultPanel" class="run-result-panel">
<component
:is="viewValuePanelVisible ? 'splitpanes' : 'div'"
<splitpanes
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100, hidden: !viewValuePanelVisible }"
:default="{ before: 50, after: 50 }"
class="run-result-panel-content"
>
<template #left-pane>
<div
:id="'run-result-left-pane-' + tab.id"
class="result-set-container"
/>
</template>
<div
:id="'run-result-result-set-' + tab.id"
class="result-set-container"
/>
<template v-if="viewValuePanelVisible" #right-pane>
<div class="value-viewer-container">
<value-viewer
v-show="selectedCell"
:cellValue="
selectedCell
? result.values[result.columns[selectedCell.dataset.col]][
selectedCell.dataset.row
]
: ''
"
/>
<div v-show="!selectedCell" class="table-preview">
No cell selected to view
<div class="result-set-container">
<div
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30" />
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]" />
<sql-table
v-if="result && !viewRecord"
:data-set="result"
:time="time"
:pageSize="pageSize"
:page="defaultPage"
:selectedCellCoordinates="defaultSelectedCell"
class="straight"
@update-selected-cell="onUpdateSelectedCell"
/>
<record
v-if="result && viewRecord"
ref="recordView"
:data-set="result"
:time="time"
:selectedColumnIndex="selectedCell ? +selectedCell.dataset.col : 0"
:rowIndex="selectedCell ? +selectedCell.dataset.row : 0"
@update-selected-cell="onUpdateSelectedCell"
/>
</div>
</template>
</component>
<template v-if="viewValuePanelVisible" #right-pane>
<value-viewer
:empty="!selectedCell"
emptyMessage="No cell selected to view"
:value="
selectedCell
? result.values[result.columns[selectedCell.dataset.col]][
selectedCell.dataset.row
]
: ''
"
/>
</template>
</splitpanes>
<side-tool-bar panel="table" @switch-to="$emit('switchTo', $event)">
<icon-button
@@ -80,78 +106,36 @@
</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"
@cancel="cancelCopy"
/>
<teleport defer :to="resultSetTeleportTarget" :disabled="!enableTeleport">
<div>
<div
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30" />
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]" />
<sql-table
v-if="result && !viewRecord"
:data-set="result"
:time="time"
:pageSize="pageSize"
:page="defaultPage"
:selectedCellCoordinates="defaultSelectedCell"
class="straight"
@update-selected-cell="onUpdateSelectedCell"
/>
<record
v-if="result && viewRecord"
ref="recordView"
:data-set="result"
:time="time"
:selectedColumnIndex="selectedCell ? +selectedCell.dataset.col : 0"
:rowIndex="selectedCell ? +selectedCell.dataset.row : 0"
@update-selected-cell="onUpdateSelectedCell"
/>
</div>
</teleport>
</div>
</template>
<script>
import Logs from '@/components/Logs'
import SqlTable from '@/components/SqlTable/index.vue'
import LoadingIndicator from '@/components/LoadingIndicator'
import SideToolBar from '../SideToolBar'
import Splitpanes from '@/components/Splitpanes'
import Logs from '@/components/Common/Logs'
import SqlTable from '@/components/SqlTable'
import LoadingIndicator from '@/components/Common/LoadingIndicator'
import SideToolBar from '@/components/SideToolBar'
import Splitpanes from '@/components/Common/Splitpanes'
import ExportToCsvIcon from '@/components/svg/exportToCsv'
import ClipboardIcon from '@/components/svg/clipboard'
import ViewCellValueIcon from '@/components/svg/viewCellValue'
import RowIcon from '@/components/svg/row'
import IconButton from '@/components/IconButton'
import IconButton from '@/components/Common/IconButton'
import csv from '@/lib/csv'
import fIo from '@/lib/utils/fileIo'
import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time'
import loadingDialog from '@/components/LoadingDialog'
import loadingDialog from '@/components/Common/LoadingDialog'
import events from '@/lib/utils/events'
import ValueViewer from './ValueViewer'
import ValueViewer from '@/components/ValueViewer'
import Record from './Record/index.vue'
export default {
@@ -172,7 +156,6 @@ export default {
Splitpanes
},
props: {
tab: Object,
result: Object,
isGettingResults: Boolean,
error: Object,
@@ -190,21 +173,8 @@ export default {
viewRecord: false,
defaultPage: 1,
defaultSelectedCell: null,
enableTeleport: this.$store.state.isWorkspaceVisible
}
},
computed: {
resultSetTeleportTarget() {
if (!this.enableTeleport) {
return undefined
}
const base = `#${
this.viewValuePanelVisible
? 'run-result-left-pane'
: 'run-result-result-set'
}`
const tabIdPostfix = `-${this.tab.id}`
return base + tabIdPostfix
enableTeleport: this.$store.state.isWorkspaceVisible,
showLoadingDialog: false
}
},
watch: {
@@ -264,14 +234,13 @@ export default {
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.$modal.show('prepareCSVCopy')
this.showLoadingDialog = true
const t0 = performance.now()
await time.sleep(0)
this.dataToCopy = csv.serialize(this.result)
const t1 = performance.now()
if (t1 - t0 < 950) {
this.$modal.hide('prepareCSVCopy')
this.copyToClipboard()
} else {
this.preparingCopy = false
@@ -287,12 +256,11 @@ export default {
copyToClipboard() {
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
this.$modal.hide('prepareCSVCopy')
this.showLoadingDialog = false
},
cancelCopy() {
this.dataToCopy = null
this.$modal.hide('prepareCSVCopy')
},
toggleViewValuePanel() {
@@ -333,19 +301,12 @@ export default {
width: 0;
}
.result-set-container,
.result-set-container > div {
.result-set-container {
position: relative;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.value-viewer-container {
height: 100%;
width: 100%;
background-color: var(--color-white);
position: relative;
}
.table-preview {
position: absolute;

View File

@@ -35,7 +35,7 @@
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
import TableDescription from './TableDescription'
import TextField from '@/components/TextField'
import TextField from '@/components/Common/TextField'
import TreeChevron from '@/components/svg/treeChevron'
import DbUploader from '@/components/DbUploader'
import ExportIcon from '@/components/svg/export'

View File

@@ -37,7 +37,7 @@
</template>
<script>
import IconButton from '@/components/IconButton'
import IconButton from '@/components/Common/IconButton'
import TableIcon from '@/components/svg/table'
import SqlEditorIcon from '@/components/svg/sqlEditor'
import DataViewIcon from '@/components/svg/dataView'
@@ -63,7 +63,9 @@ export default {
border-left: 1px solid var(--color-border-light);
padding: 6px;
}
</style>
<style>
.side-tool-bar-divider {
width: 26px;
height: 1px;

View File

@@ -34,7 +34,7 @@ import 'codemirror/theme/neo.css'
import 'codemirror/addon/hint/show-hint.css'
import 'codemirror/addon/display/autorefresh.js'
import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton'
import IconButton from '@/components/Common/IconButton'
import RunIcon from '@/components/svg/run'
export default {

View File

@@ -69,7 +69,7 @@
</template>
<script>
import Pager from './Pager.vue'
import Pager from '@/components/Common/Pager.vue'
export default {
name: 'SqlTable',

View File

@@ -37,7 +37,6 @@
:disabled="!enableTeleport"
>
<run-result
:tab="tab"
:result="tab.result"
:isGettingResults="tab.isGettingResults"
:error="tab.error"
@@ -64,11 +63,11 @@
</template>
<script>
import Splitpanes from '@/components/Splitpanes'
import SqlEditor from './SqlEditor'
import DataView from './DataView'
import RunResult from './RunResult'
import { nextTick } from 'vue'
import Splitpanes from '@/components/Common/Splitpanes'
import SqlEditor from '@/components/SqlEditor'
import DataView from '@/components/DataView'
import RunResult from '@/components/RunResult'
import { nextTick, computed } from 'vue'
import events from '@/lib/utils/events'
@@ -80,6 +79,11 @@ export default {
RunResult,
Splitpanes
},
provide() {
return {
tabLayout: computed(() => this.tab.layout)
}
},
props: {
tab: Object
},

View File

@@ -1,48 +1,56 @@
<template>
<div class="value-viewer">
<div class="value-viewer-toolbar">
<button
v-for="format in formats"
:key="format.value"
type="button"
:aria-selected="currentFormat === format.value"
:class="format.value"
@click="currentFormat = format.value"
>
{{ format.text }}
</button>
<template v-if="!empty">
<div class="value-viewer-toolbar">
<button
v-for="format in formats"
:key="format.value"
type="button"
:aria-selected="currentFormat === format.value"
:class="format.value"
@click="currentFormat = format.value"
>
{{ format.text }}
</button>
<button type="button" class="copy" @click="copyToClipboard">Copy</button>
<button
type="button"
class="line-wrap"
:aria-selected="lineWrapping === true"
@click="lineWrapping = !lineWrapping"
>
Line wrap
</button>
</div>
<div class="value-body">
<codemirror
v-if="currentFormat === 'json' && formattedJson"
:value="formattedJson"
:options="cmOptions"
class="json-value original-style"
/>
<pre
v-if="currentFormat === 'text'"
:class="[
'text-value',
{ 'meta-value': isNull || isBlob },
{ 'line-wrap': lineWrapping }
]"
>{{ cellText }}</pre
>
<logs
v-if="messages && messages.length > 0"
:messages="messages"
class="messages"
/>
<button type="button" class="copy" @click="copyToClipboard">
Copy
</button>
<button
type="button"
class="line-wrap"
:aria-selected="lineWrapping === true"
@click="lineWrapping = !lineWrapping"
>
Line wrap
</button>
</div>
<div class="value-body">
<codemirror
v-if="currentFormat === 'json' && formattedJson"
:value="formattedJson"
:options="cmOptions"
class="json-value original-style"
/>
<pre
v-if="currentFormat === 'text'"
:class="[
'text-value',
{ 'meta-value': isNull || isBlob },
{ 'line-wrap': lineWrapping }
]"
>{{ cellText }}</pre
>
<logs
v-if="messages && messages.length > 0"
:messages="messages"
class="messages"
/>
</div>
</template>
<div v-show="empty" class="empty-message">
{{ emptyMessage }}
</div>
</div>
</template>
@@ -57,15 +65,22 @@ import 'codemirror/addon/fold/foldgutter.css'
import 'codemirror/addon/fold/brace-fold.js'
import 'codemirror/theme/neo.css'
import cIo from '@/lib/utils/clipboardIo'
import Logs from '@/components/Logs'
import Logs from '@/components/Common/Logs'
export default {
name: 'ValueViewer',
components: {
Codemirror,
Logs
},
props: {
cellValue: [String, Number, Uint8Array]
value: [String, Number, Uint8Array],
empty: Boolean,
emptyMessage: String,
defaultFormat: {
type: String,
default: 'text'
}
},
data() {
return {
@@ -73,7 +88,7 @@ export default {
{ text: 'Text', value: 'text' },
{ text: 'JSON', value: 'json' }
],
currentFormat: 'text',
currentFormat: this.defaultFormat,
lineWrapping: false,
formattedJson: '',
messages: []
@@ -94,13 +109,13 @@ export default {
}
},
isBlob() {
return this.cellValue && ArrayBuffer.isView(this.cellValue)
return this.value && ArrayBuffer.isView(this.value)
},
isNull() {
return this.cellValue === null
return this.value === null
},
cellText() {
const value = this.cellValue
const value = this.value
if (this.isNull) {
return 'NULL'
}
@@ -111,17 +126,23 @@ export default {
}
},
watch: {
currentFormat() {
this.messages = []
this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
currentFormat: {
immediate: true,
handler() {
this.messages = []
this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.value)
}
}
},
cellValue() {
this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
value: {
immediate: true,
handler() {
this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.value)
}
}
}
},
@@ -141,7 +162,7 @@ export default {
},
copyToClipboard() {
cIo.copyText(
this.currentFormat === 'json' ? this.formattedJson : this.cellValue,
this.currentFormat === 'json' ? this.formattedJson : this.value,
'The value is copied to clipboard.'
)
}
@@ -153,8 +174,10 @@ export default {
.value-viewer {
background-color: var(--color-white);
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.value-viewer-toolbar {
display: flex;
@@ -219,4 +242,14 @@ export default {
width: 1px;
background: var(--color-text-base);
}
.empty-message {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 13px;
text-align: center;
}
</style>

View File

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

View File

@@ -0,0 +1,129 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_2236_2707)">
<path
d="M8.11441 0.0274963C7.73011 0.0274918 7.35916 0.168422 7.0718
0.423594C6.78445 0.678766 6.60065 1.03046 6.55522 1.41207L7.89167
1.57122C7.89815 1.51669 7.9244 1.46644 7.96546 1.42997C8.00651 1.39351
8.05951 1.37337 8.11441 1.37337V0.0274963ZM9.88559
0.0274963H8.11441V1.37337H9.88559V0.0274963ZM11.4448
1.41207C11.3994 1.03046 11.2156 0.678766 10.9282 0.423594C10.6408
0.168422 10.2699 0.0274918 9.88559 0.0274963V1.37337C9.94049 1.37337
9.99349 1.39351 10.0345 1.42997C10.0756 1.46644 10.1018 1.51669
10.1083 1.57122L11.4448 1.41207ZM11.5815 2.56055L11.4448
1.41207L10.1083 1.57122L10.2449 2.7197L11.5815 2.56055ZM14.349
3.0888L13.282 3.5464L13.8123 4.78326L14.8793 4.32566L14.349
3.0888ZM16.3276 3.74682C16.1355 3.41401 15.8279 3.16322 15.4633
3.04195C15.0986 2.92067 14.7021 2.93735 14.349 3.0888L14.8793
4.32566C14.9298 4.304 14.9865 4.30172 15.0386 4.31904C15.0907
4.33636 15.1346 4.3722 15.1621 4.41976L16.3276 3.74682ZM17.2132
5.28067L16.3276 3.74682L15.1621 4.41976L16.0477 5.95361L17.2132
5.28067ZM16.7937 7.32326C17.1015 7.09311 17.3142 6.75809 17.3915
6.38165C17.4688 6.00521 17.4054 5.61348 17.2132 5.28067L16.0477
5.95361C16.0751 6.00117 16.0842 6.05714 16.0731 6.11093C16.0621
6.16471 16.0317 6.21257 15.9877 6.24544L16.7937 7.32326ZM15.7861
8.07673L16.7937 7.32326L15.9877 6.24544L14.9801 6.99891L15.7861
8.07673ZM16.7937 10.6767L15.7861 9.92327L14.9802 11.0011L15.9877
11.7546L16.7937 10.6767ZM17.2132 12.7193C17.4054 12.3865 17.4688
11.9948 17.3915 11.6183C17.3142 11.2419 17.1015 10.9069 16.7937
10.6767L15.9877 11.7546C16.0317 11.7874 16.0621 11.8353 16.0731
11.8891C16.0842 11.9429 16.0751 11.9988 16.0477 12.0464L17.2132
12.7193ZM16.3276 14.2532L17.2132 12.7193L16.0477 12.0464L15.1621
13.5802L16.3276 14.2532ZM14.349 14.9112C14.7021 15.0626 15.0986
15.0793 15.4633 14.958C15.8279 14.8368 16.1355 14.586 16.3276
14.2532L15.1621 13.5802C15.1346 13.6278 15.0907 13.6636 15.0386
13.6809C14.9865 13.6982 14.9298 13.6959 14.8793 13.6742L14.349
14.9112ZM13.282 14.4536L14.349 14.9112L14.8793 13.6742L13.8125
13.2167L13.282 14.4536ZM11.4448 16.5879L11.5814 15.4394L10.245
15.2803L10.1083 16.4288L11.4448 16.5879ZM9.88559 17.9725C10.2699
17.9725 10.6408 17.8316 10.9282 17.5764C11.2156 17.3212 11.3994
16.9695 11.4448 16.5879L10.1083 16.4288C10.1018 16.4833 10.0756
16.5336 10.0345 16.57C9.99349 16.6065 9.94049 16.6266 9.88559
16.6266V17.9725ZM8.11441
17.9725H9.88559V16.6266H8.11441V17.9725ZM6.55522
16.5879C6.60065 16.9695 6.78445 17.3212 7.0718
17.5764C7.35916 17.8316 7.73011 17.9725 8.11441
17.9725V16.6266C8.05951 16.6266 8.00651 16.6065
7.96546 16.57C7.9244 16.5336 7.89815 16.4833 7.89167
16.4288L6.55522 16.5879ZM6.44172 15.6342L6.55522 16.5879L7.89167
16.4288L7.77817 15.475L6.44172 15.6342ZM4.00097 13.2967L3.12066
13.6742L3.65104 14.9112L4.53136 14.5337L4.00097 13.2967ZM3.12066
13.6742C3.07021 13.6959 3.01346 13.6982 2.96138 13.6809C2.90929
13.6636 2.86536 13.6278 2.83791 13.5802L1.67238 14.2532C1.86453
14.586 2.17205 14.8368 2.53672 14.958C2.90138 15.0793 3.29785
15.0626 3.65104 14.9112L3.12066 13.6742ZM2.83791 13.5802L1.95233
12.0464L0.786798 12.7193L1.67238 14.2532L2.83791 13.5802ZM1.95233
12.0464C1.92487 11.9988 1.91582 11.9429 1.92688 11.8891C1.93794
11.8353 1.96834 11.7874 2.01233 11.7546L1.20626 10.6767C0.898499
10.9069 0.685823 11.2419 0.608517 11.6183C0.531211 11.9948 0.594643
12.3865 0.786798 12.7193L1.95233 12.0464ZM2.01233 11.7546L2.70007
11.2403L1.89389 10.1625L1.20626 10.6767L2.01233 11.7546ZM1.20626
7.32326L1.894 7.83761L2.70007 6.75979L2.01233 6.24544L1.20626
7.32326ZM0.786798 5.28067C0.594643 5.61348 0.531211 6.00521
0.608517 6.38165C0.685823 6.75809 0.898499 7.09311 1.20626
7.32326L2.01233 6.24544C1.96834 6.21257 1.93794 6.16471 1.92688
6.11093C1.91582 6.05714 1.92487 6.00117 1.95233 5.95361L0.786798
5.28067ZM1.67238 3.74682L0.786798 5.28067L1.95233 5.95361L2.83791
4.41976L1.67238 3.74682ZM3.65104 3.0888C3.29785 2.93735 2.90138
2.92067 2.53672 3.04195C2.17205 3.16322 1.86453 3.41401 1.67238
3.74682L2.83791 4.41976C2.86536 4.37223 2.90929 4.33641 2.96138
4.31909C3.01346 4.30176 3.07021 4.30414 3.12066 4.32577L3.65104
3.0888ZM4.53136 3.46632L3.65104 3.0888L3.12066 4.32577L4.00097
4.70329L4.53136 3.46632ZM6.55522 1.41207L6.44172 2.36584L7.77817
2.52499L7.89167 1.57122L6.55522 1.41207ZM6.3585 4.5022C7.04018
4.11795 7.6696 3.4366 7.77817 2.52499L6.44172 2.36584C6.39887 2.72575
6.13116 3.08544 5.69756 3.32994L6.3585 4.5022ZM4.00097 4.70329C4.81792
5.05367 5.68948 4.87938 6.3585 4.5022L5.69756 3.32994C5.28213 3.56412
4.85852 3.60663 4.53136 3.46632L4.00097 4.70329ZM3.72866 9C3.72866
8.20369 3.44288 7.3153 2.70007 6.75979L1.894 7.83761C2.19884 8.06562
2.38278 8.48834 2.38278 9H3.72866ZM6.3585 13.4978C5.68948 13.1206
4.81792 12.9463 4.00097 13.2967L4.53136 14.5337C4.85852 14.3934
5.28213 14.436 5.69756 14.6703L6.3585 13.4978ZM2.70007
11.2403C3.44299 10.6848 3.72866 9.79631 3.72866 9H2.38278C2.38278
9.51177 2.19873 9.9346 1.89389 10.1625L2.70007 11.2403ZM11.5243
13.4358C10.9034 13.8057 10.3448 14.4432 10.245 15.2803L11.5814
15.4394C11.6183 15.1293 11.8415 14.8134 12.2132 14.5919L11.5243
13.4358ZM13.8125 13.2167C13.0169 12.8756 12.1673 13.0527 11.5243
13.4358L12.2132 14.5919C12.5925 14.3659 12.9839 14.3258 13.282
14.4536L13.8125 13.2167ZM7.77817 15.475C7.6696 14.5635 7.04018
13.8822 6.3585 13.4978L5.69756 14.6703C6.13116 14.9147 6.39887
15.2742 6.44172 15.6342L7.77817 15.475ZM14.047 9C14.047 9.71656
14.316 10.5045 14.9802 11.0011L15.7861 9.92327C15.547 9.74449 15.3929
9.41139 15.3929 9H14.047ZM14.9801 6.99891C14.3159 7.49553 14.047
8.28332 14.047 9H15.3929C15.3929 8.58861 15.547 8.2555 15.7861
8.07673L14.9801 6.99891ZM11.5243 4.56422C12.1673 4.94734 13.0168
5.12444 13.8123 4.78326L13.282 3.5464C12.9839 3.67426 12.5925
3.63399 12.2132 3.408L11.5243 4.56422ZM10.2449 2.7197C10.3446
3.55683 10.9034 4.19433 11.5243 4.56422L12.2132 3.408C11.8415
3.1866 11.6184 2.87077 11.5815 2.56055L10.2449 2.7197Z"
fill="#A2B1C6"
/>
<path
d="M11.0935 9C11.0935 7.84373 10.1562 6.90642 8.99988 6.90642C7.84361
6.90642 6.90629 7.84373 6.90629 9C6.90629 10.1563 7.84361 11.0936
8.99988 11.0936C10.1562 11.0936 11.0935 10.1563 11.0935 9ZM12.2898
9C12.2898 10.817 10.8169 12.2899 8.99988 12.2899C7.18289 12.2899
5.70996 10.817 5.70996 9C5.70996 7.18301 7.18289 5.71008 8.99988
5.71008C10.8169 5.71008 12.2898 7.18301 12.2898 9Z"
fill="#A2B1C6"
/>
</g>
<defs>
<clipPath id="clip0_2236_2707">
<rect width="18" height="18" fill="white" />
</clipPath>
</defs>
</svg>
</template>
<script>
export default {
name: 'SettingsIcon'
}
</script>

View File

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

View File

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

463
src/lib/graphHelper.js Normal file
View File

@@ -0,0 +1,463 @@
import { COLOR_PICKER_CONSTANTS } from 'react-colorscales'
import tinycolor from 'tinycolor2'
const TYPE_NODE = 0
const TYPE_EDGE = 1
const DEFAULT_SCALE = COLOR_PICKER_CONSTANTS.DEFAULT_SCALE
export function 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 { objectType, nodeId } = options.structure
if (objectType && nodeId) {
const nodes = dataSources[docColumn]
.map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_NODE)
nodes.forEach(node => {
if (node[nodeId]) {
graph.addNode(node[nodeId], {
data: node
})
}
})
}
}
export function buildEdges(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0]
const { objectType, edgeSource, edgeTarget } = options.structure
if (objectType && edgeSource && edgeTarget) {
const edges = dataSources[docColumn]
.map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_EDGE)
edges.forEach(edge => {
const source = edge[edgeSource]
const target = edge[edgeTarget]
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.addEdge(source, target, {
data: edge
})
}
})
}
}
export function updateNodes(graph, attributeUpdates) {
const changeMethods = []
if (attributeUpdates.label) {
changeMethods.push(getUpdateLabelMethod(attributeUpdates.label))
}
if (attributeUpdates.size) {
changeMethods.push(getUpdateSizeMethod(graph, attributeUpdates.size))
}
if (attributeUpdates.color) {
changeMethods.push(getUpdateNodeColorMethod(graph, attributeUpdates.color))
}
graph.forEachNode(nodeId => {
graph.updateNode(nodeId, attributes => {
const newAttributes = { ...attributes }
changeMethods.forEach(method => method(newAttributes, nodeId))
return newAttributes
})
})
}
export function updateEdges(graph, attributeUpdates) {
const changeMethods = []
if (attributeUpdates.label) {
changeMethods.push(getUpdateLabelMethod(attributeUpdates.label))
}
if (attributeUpdates.size) {
changeMethods.push(getUpdateSizeMethod(graph, attributeUpdates.size))
}
if (attributeUpdates.color) {
changeMethods.push(getUpdateEdgeColorMethod(graph, attributeUpdates.color))
}
if ('showDirection' in attributeUpdates) {
changeMethods.push(
attributes =>
(attributes.type = attributeUpdates.showDirection ? 'arrow' : 'line')
)
}
graph.forEachEdge((edgeId, attributes, source, target) => {
graph.updateEdgeWithKey(edgeId, source, target, attr => {
const newAttributes = { ...attr }
changeMethods.forEach(method => method(newAttributes, edgeId))
return newAttributes
})
})
}
export function reduceNodes(nodeId, nodeData, interactionState, settings) {
const {
selectedNodeId,
hoveredNodeId,
selectedEdgeId,
hoveredEdgeId,
neighborsOfSelectedNode,
neighborsOfHoveredNode,
selectedEdgeExtremities,
hoveredEdgeExtremities
} = interactionState
const res = { ...nodeData }
if (selectedNodeId || hoveredNodeId || hoveredEdgeId || selectedEdgeId) {
res.zIndex = 2
res.highlighted = nodeId === selectedNodeId || nodeId === hoveredNodeId
const isInHoveredFamily =
nodeId === hoveredNodeId ||
neighborsOfHoveredNode?.has(nodeId) ||
hoveredEdgeExtremities.includes(nodeId)
const isInSelectedFamily =
nodeId === selectedNodeId ||
neighborsOfSelectedNode?.has(nodeId) ||
selectedEdgeExtremities.includes(nodeId)
if (isInSelectedFamily || isInHoveredFamily) {
res.forceLabel = true
} else {
res.color = getDiminishedColor(
nodeData.color,
settings.style.backgroundColor
)
res.label = ''
res.zIndex = 1
}
}
return res
}
export function reduceEdges(
edgeId,
edgeData,
interactionState,
settings,
graph
) {
const {
selectedNodeId,
hoveredNodeId,
selectedEdgeId,
hoveredEdgeId,
neighborsOfSelectedNode,
neighborsOfHoveredNode
} = interactionState
const res = { ...edgeData }
if (hoveredEdgeId || selectedEdgeId || selectedNodeId || hoveredNodeId) {
const extremities = graph.extremities(edgeId)
res.zIndex = 2
const isHighlighted = hoveredEdgeId === edgeId || selectedEdgeId === edgeId
let isVisible
if (settings.style.highlightMode === 'node_alone') {
isVisible = isHighlighted
} else if (settings.style.highlightMode === 'node_and_neighbors') {
isVisible =
isHighlighted ||
(selectedNodeId && extremities.includes(selectedNodeId)) ||
(hoveredNodeId && extremities.includes(hoveredNodeId))
} else {
isVisible =
isHighlighted ||
(selectedNodeId &&
extremities.every(
n => n === selectedNodeId || neighborsOfSelectedNode.has(n)
)) ||
(hoveredNodeId &&
extremities.every(
n => n === hoveredNodeId || neighborsOfHoveredNode.has(n)
))
}
if (isHighlighted) {
res.size = res.size * 2
res.forceLabel = true
} else if (!isVisible) {
res.color = getDiminishedColor(
edgeData.color,
settings.style.backgroundColor
)
res.zIndex = 1
res.label = ''
}
}
return res
}
export function getDiminishedColor(color, bgColor) {
const colorObj = tinycolor(color)
const colorOpacity = colorObj.getAlpha()
colorObj.setAlpha(0.25 * colorOpacity)
const fg = colorObj.toRgb()
const bg = tinycolor(bgColor).toRgb()
const r = Math.round(fg.r * fg.a + bg.r * (1 - fg.a))
const g = Math.round(fg.g * fg.a + bg.g * (1 - fg.a))
const b = Math.round(fg.b * fg.a + bg.b * (1 - fg.a))
return tinycolor({ r, g, b, a: 1 }).toHexString()
}
function getUpdateLabelMethod(labelSettings) {
const { source, color } = labelSettings
return attributes => {
const label = attributes.data[source] ?? ''
attributes.label = label.toString()
attributes.labelColor = color
}
}
function getUpdateSizeMethod(graph, sizeSettings) {
const { type, value, source, scale, mode, min, method } = sizeSettings
if (type === 'constant') {
return attributes => (attributes.size = value)
} else if (type === 'variable') {
return attributes => {
attributes.size = getVariabledSize(
mode,
attributes.data[source],
scale,
min
)
}
} else {
return (attributes, nodeId) => {
attributes.size = getVariabledSize(
mode,
graph[method](nodeId),
scale,
min
)
}
}
}
function getDirectVariableColorUpdateMethod(source, opacity = 100) {
return attributes => {
const color = tinycolor(attributes.data[source])
const colorOpacity = color.getAlpha()
attributes.color = color
.setAlpha((opacity / 100) * colorOpacity)
.toHex8String()
}
}
function getUpdateNodeColorMethod(graph, colorSettings) {
const {
type,
value,
source,
sourceUsage,
colorscale,
colorscaleDirection,
mode,
method,
opacity
} = colorSettings
if (type === 'constant') {
const color = tinycolor(value)
const colorOpacity = color.getAlpha()
return attributes =>
(attributes.color = color
.setAlpha((opacity / 100) * colorOpacity)
.toHex8String())
} else if (type === 'variable') {
return sourceUsage === 'map_to'
? getColorMethod(
graph,
mode,
(nodeId, attributes) => attributes.data[source],
colorscale,
colorscaleDirection,
getNodeValueScale,
opacity
)
: getDirectVariableColorUpdateMethod(source, opacity)
} else {
return getColorMethod(
graph,
mode,
nodeId => graph[method](nodeId),
colorscale,
colorscaleDirection,
getNodeValueScale,
opacity
)
}
}
function getUpdateEdgeColorMethod(graph, colorSettings) {
const {
type,
value,
source,
sourceUsage,
colorscale,
colorscaleDirection,
mode
} = colorSettings
if (type === 'constant') {
return attributes => (attributes.color = value)
} else {
return sourceUsage === 'map_to'
? getColorMethod(
graph,
mode,
(edgeId, attributes) => attributes.data[source],
colorscale,
colorscaleDirection,
getEdgeValueScale
)
: getDirectVariableColorUpdateMethod(source)
}
}
function getVariabledSize(mode, value, scale, min) {
if (mode === 'diameter') {
return Math.max((value / 2) * scale, min / 2)
} else if (mode === 'area') {
return Math.max(Math.sqrt((value / 2) * scale), min / 2)
} else {
return Math.max(value * scale, min)
}
}
function getColorMethod(
graph,
mode,
sourceGetter,
selectedColorscale,
colorscaleDirection,
valueScaleGetter,
opacity = 100
) {
const opacityFactor = opacity / 100
const valueScale = valueScaleGetter(graph, sourceGetter)
let colorscale = selectedColorscale || DEFAULT_SCALE
if (colorscaleDirection === 'reversed') {
colorscale = [...colorscale].reverse()
}
if (mode === 'categorical') {
const colorMap = Object.fromEntries(
valueScale.map((value, index) => [
value,
colorscale[index % colorscale.length]
])
)
return (attributes, nodeId) => {
const category = sourceGetter(nodeId, attributes)
attributes.color = tinycolor(colorMap[category])
.setAlpha(opacityFactor)
.toHex8String()
}
} else {
const min = valueScale[0]
const max = valueScale[valueScale.length - 1]
const normalizedColorscale = colorscale.map((color, index) => [
index / (colorscale.length - 1),
tinycolor(color).toRgb()
])
return (attributes, nodeId) => {
const value = sourceGetter(nodeId, attributes)
const normalizedValue = (value - min) / (max - min)
if (isNaN(normalizedValue)) {
attributes.color = tinycolor('#000000')
.setAlpha(opacityFactor)
.toHex8String()
return
}
const exactMatch = normalizedColorscale.find(
([value]) => value === normalizedValue
)
if (exactMatch) {
attributes.color = tinycolor(exactMatch[1])
.setAlpha(opacityFactor)
.toHex8String()
return
}
const rightColorIndex = normalizedColorscale.findIndex(
([value]) => value > normalizedValue
)
const leftColorIndex = rightColorIndex - 1
const right = normalizedColorscale[rightColorIndex]
const left = normalizedColorscale[leftColorIndex]
const interpolationFactor =
(normalizedValue - left[0]) / (right[0] - left[0])
const r0 = left[1].r
const g0 = left[1].g
const b0 = left[1].b
const r1 = right[1].r
const g1 = right[1].g
const b1 = right[1].b
attributes.color = tinycolor({
r: r0 + interpolationFactor * (r1 - r0),
g: g0 + interpolationFactor * (g1 - g0),
b: b0 + interpolationFactor * (b1 - b0)
})
.setAlpha(opacityFactor)
.toHex8String()
}
}
}
function getNodeValueScale(graph, sourceGetter) {
const scaleSet = graph.reduceNodes((res, nodeId, attributes) => {
res.add(sourceGetter(nodeId, attributes))
return res
}, new Set())
return Array.from(scaleSet).sort((a, b) => a - b)
}
function getEdgeValueScale(graph, sourceGetter) {
const scaleSet = graph.reduceEdges((res, edgeId, attributes) => {
res.add(sourceGetter(edgeId, attributes))
return res
}, new Set())
return Array.from(scaleSet).sort((a, b) => a - b)
}

View File

@@ -1,12 +1,22 @@
export default {
_migrate(installedVersion, inquiries) {
if (installedVersion === 1) {
inquiries.forEach(inquire => {
inquire.viewType = 'chart'
inquire.viewOptions = inquire.chart
delete inquire.chart
if (installedVersion < 2) {
inquiries.forEach(inquiry => {
inquiry.viewType = 'chart'
inquiry.viewOptions = inquiry.chart
delete inquiry.chart
})
return inquiries
}
if (installedVersion < 3) {
inquiries.forEach(inquiry => {
if (inquiry.viewType === 'graph') {
inquiry.viewOptions.style.nodes.color.opacity = 100
inquiry.viewOptions.style.highlightMode = 'node_and_neighbors'
}
})
}
return inquiries
}
}

View File

@@ -4,11 +4,13 @@ import events from '@/lib/utils/events'
import migration from './_migrations'
const migrate = migration._migrate
const myInquiriesKey = 'myInquiries'
export default {
version: 2,
version: 3,
myInquiriesKey,
getStoredInquiries() {
let myInquiries = JSON.parse(localStorage.getItem('myInquiries'))
let myInquiries = JSON.parse(localStorage.getItem(myInquiriesKey))
if (!myInquiries) {
const oldInquiries = localStorage.getItem('myQueries')
if (oldInquiries) {
@@ -19,14 +21,21 @@ export default {
return []
}
return (myInquiries && myInquiries.inquiries) || []
if (myInquiries.version === 2) {
myInquiries = migrate(2, myInquiries.inquiries)
this.updateStorage(myInquiries)
return myInquiries
}
return myInquiries.inquiries || []
},
duplicateInquiry(baseInquiry) {
const newInquiry = JSON.parse(JSON.stringify(baseInquiry))
newInquiry.name = newInquiry.name + ' Copy'
newInquiry.id = nanoid()
newInquiry.createdAt = new Date()
newInquiry.createdAt = new Date().toJSON()
newInquiry.updatedAt = new Date().toJSON()
delete newInquiry.isPredefined
return newInquiry
@@ -38,7 +47,7 @@ export default {
updateStorage(inquiries) {
localStorage.setItem(
'myInquiries',
myInquiriesKey,
JSON.stringify({ version: this.version, inquiries })
)
},
@@ -60,6 +69,8 @@ export default {
// Turn data into array if they are not
inquiryList = !Array.isArray(inquiries) ? [inquiries] : inquiries
inquiryList = migrate(1, inquiryList)
} else if (inquiries.version === 2) {
inquiryList = migrate(2, inquiries.inquiries)
} else {
inquiryList = inquiries.inquiries || []
}
@@ -79,11 +90,11 @@ export default {
importInquiries() {
return fu.importFile().then(str => {
const inquires = this.deserialiseInquiries(str)
const inquiries = this.deserialiseInquiries(str)
events.send('inquiry.import', inquires.length)
events.send('inquiry.import', inquiries.length)
return inquires
return inquiries
})
},
export(inquiryList, fileName) {
@@ -99,6 +110,8 @@ export default {
if (!data.version) {
return data.length > 0 ? migrate(1, data) : []
} else if (data.version === 2) {
return migrate(2, data.inquiries)
} else {
return data.inquiries
}

View File

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

View File

@@ -5,6 +5,7 @@ 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

@@ -1,13 +1,13 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Workspace from '@/views/MainView/Workspace'
import Inquiries from '@/views/MainView/Inquiries'
import Workspace from '@/views/Workspace'
import Inquiries from '@/views/Inquiries'
import Welcome from '@/views/Welcome'
import MainView from '@/views/MainView'
import LoadView from '@/views/LoadView'
import store from '@/store'
import database from '@/lib/database'
const routes = [
export const routes = [
{
path: '/',
name: 'Welcome',

View File

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

View File

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

View File

@@ -184,14 +184,14 @@
</template>
<script>
import RenameIcon from './svg/rename'
import CopyIcon from './svg/copy'
import RenameIcon from '@/components/svg/rename'
import CopyIcon from '@/components/svg/copy'
import ExportIcon from '@/components/svg/export'
import DeleteIcon from './svg/delete'
import DeleteIcon from '@/components/svg/delete'
import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import CheckBox from '@/components/CheckBox'
import LoadingIndicator from '@/components/LoadingIndicator'
import TextField from '@/components/Common/TextField'
import CheckBox from '@/components/Common/CheckBox'
import LoadingIndicator from '@/components/Common/LoadingIndicator'
import tooltipMixin from '@/tooltipMixin'
import storedInquiries from '@/lib/storedInquiries'
import eventBus from '@/lib/eventBus'

View File

@@ -15,7 +15,7 @@
<script>
import fu from '@/lib/utils/fileIo'
import database from '@/lib/database'
import Logs from '@/components/Logs'
import Logs from '@/components/Common/Logs'
import events from '@/lib/utils/events'
export default {

View File

@@ -10,7 +10,7 @@
</template>
<script>
import MainMenu from './MainMenu'
import MainMenu from '@/components/MainMenu'
import '@/assets/styles/scrollbars.css'
export default {

View File

@@ -1,309 +0,0 @@
<template>
<div class="pivot-ui">
<div :class="{ collapsed }">
<div class="row">
<label>Columns</label>
<multiselect
v-model="cols"
class="sqliteviz-select cols"
:options="colsToSelect"
:disabled="colsToSelect.length === 0"
:multiple="true"
: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 #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>
<script>
import $ from 'jquery'
import Multiselect from 'vue-multiselect'
import PivotSortBtn from './PivotSortBtn'
import {
renderers,
aggregators,
zeroValAggregators,
twoValAggregators
} from '../pivotHelper'
export default {
name: 'PivotUi',
components: {
Multiselect,
PivotSortBtn
},
props: {
keyNames: Array,
modelValue: Object
},
emits: ['update:modelValue', 'update'],
data() {
const aggregatorName =
(this.modelValue && this.modelValue.aggregatorName) || 'Count'
const rendererName =
(this.modelValue && this.modelValue.rendererName) || 'Table'
return {
collapsed: false,
renderer: {
name: rendererName,
fun: $.pivotUtilities.renderers[rendererName]
},
aggregator: {
name: aggregatorName,
fun: $.pivotUtilities.aggregators[aggregatorName]
},
rows: (this.modelValue && this.modelValue.rows) || [],
cols: (this.modelValue && this.modelValue.cols) || [],
val1:
(this.modelValue && this.modelValue.vals && this.modelValue.vals[0]) ||
'',
val2:
(this.modelValue && this.modelValue.vals && this.modelValue.vals[1]) ||
'',
colOrder: (this.modelValue && this.modelValue.colOrder) || 'key_a_to_z',
rowOrder: (this.modelValue && this.modelValue.rowOrder) || 'key_a_to_z'
}
},
computed: {
valCount() {
if (zeroValAggregators.includes(this.aggregator.name)) {
return 0
}
if (twoValAggregators.includes(this.aggregator.name)) {
return 2
}
return 1
},
renderers() {
return renderers
},
aggregators() {
return aggregators
},
rowsToSelect() {
return this.keyNames.filter(key => !this.cols.includes(key))
},
colsToSelect() {
return this.keyNames.filter(key => !this.rows.includes(key))
}
},
watch: {
renderer() {
this.returnValue()
},
aggregator() {
this.returnValue()
},
rows() {
this.returnValue()
},
cols() {
this.returnValue()
},
val1() {
this.returnValue()
},
val2() {
this.returnValue()
},
colOrder() {
this.returnValue()
},
rowOrder() {
this.returnValue()
}
},
methods: {
returnValue() {
const vals = []
for (let i = 1; i <= this.valCount; i++) {
vals.push(this[`val${i}`])
}
this.$emit('update')
this.$emit('update:modelValue', {
rows: this.rows,
cols: this.cols,
colOrder: this.colOrder,
rowOrder: this.rowOrder,
aggregator: this.aggregator.fun(vals),
aggregatorName: this.aggregator.name,
renderer: this.renderer.fun,
rendererName: this.renderer.name,
vals
})
}
}
}
</script>
<style scoped>
.pivot-ui {
padding: 12px 24px;
color: var(--color-text-base);
font-size: 12px;
border-bottom: 1px solid var(--color-border-light);
background-color: var(--color-bg-light);
}
.pivot-ui .row {
display: flex;
align-items: center;
margin: 12px 0;
}
.pivot-ui .row label {
width: 76px;
flex-shrink: 0;
}
.pivot-ui .row .sqliteviz-select.short {
width: 220px;
flex-shrink: 0;
}
.pivot-ui .row .aggr-arg {
margin-left: 12px;
max-width: 220px;
}
.pivot-ui .row .sort-btn {
margin-left: 12px;
flex-shrink: 0;
}
.collapsed {
display: none;
}
.switcher {
display: block;
width: min-content;
white-space: nowrap;
margin: auto;
cursor: pointer;
}
.switcher:hover {
color: var(--color-accent);
}
</style>

View File

@@ -17,9 +17,9 @@
</template>
<script>
import Splitpanes from '@/components/Splitpanes'
import Schema from './Schema'
import Tabs from './Tabs'
import Splitpanes from '@/components/Common/Splitpanes'
import Schema from '@/components/Schema'
import Tabs from '@/components/Tabs'
import events from '@/lib/utils/events'
export default {
@@ -42,8 +42,8 @@ export default {
) {
const stmt = [
'/*',
' * Your database is empty. In order to start building charts',
' * you should create a table and insert data into it.',
' * Your database is empty. In order to start building data visualisations',
' * you should create tables and insert data into them.',
' */',
'CREATE TABLE house',
'(',
@@ -54,7 +54,20 @@ export default {
"('Gryffindor', 100),",
"('Hufflepuff', 90),",
"('Ravenclaw', 95),",
"('Slytherin', 80);"
"('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');"
].join('\n')
const tabId = await this.$store.dispatch('addTab', { query: stmt })

View File

@@ -1,13 +1,22 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { shallowMount } from '@vue/test-utils'
import { shallowMount, mount } from '@vue/test-utils'
import { createStore } from 'vuex'
import App from '@/App'
import App from '@/App.vue'
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()
})
@@ -59,4 +68,167 @@ 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()
})
})

View File

@@ -1,7 +1,7 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount, flushPromises } from '@vue/test-utils'
import Chart from '@/views/MainView/Workspace/Tabs/Tab/DataView/Chart/index.vue'
import Chart from '@/components/Chart.vue'
import chartHelper from '@/lib/chartHelper'
import * as dereference from 'react-chart-editor/lib/lib/dereference'
import fIo from '@/lib/utils/fileIo'
@@ -15,7 +15,6 @@ describe('Chart.vue', () => {
})
it('getOptionsForSave called with proper arguments', () => {
// mount the component
const wrapper = mount(Chart, {
global: {
mocks: { $store }
@@ -30,7 +29,6 @@ describe('Chart.vue', () => {
})
it('emits update when plotly updates', async () => {
// mount the component
const wrapper = mount(Chart, {
global: {
mocks: { $store }
@@ -48,7 +46,6 @@ describe('Chart.vue', () => {
points: [80]
}
// mount the component
const wrapper = mount(Chart, {
props: {
dataSources,
@@ -131,6 +128,8 @@ describe('Chart.vue', () => {
expect(plot.scrollWidth).not.to.equal(initialPlotWidth)
expect(plot.scrollHeight).not.to.equal(initialPlotHeight)
container.style.width = 'unset'
container.style.height = 'unset'
wrapper.unmount()
})
@@ -187,7 +186,8 @@ describe('Chart.vue', () => {
const wrapper = mount(Chart, {
attachTo: document.body,
props: {
dataSources
dataSources,
showViewSettings: true
},
global: {
mocks: { $store }
@@ -207,4 +207,41 @@ 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,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import CheckBox from '@/components/CheckBox'
import CheckBox from '@/components/Common/CheckBox'
describe('CheckBox', () => {
it('unchecked by default', () => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import LoadingIndicator from '@/components/LoadingIndicator'
import LoadingIndicator from '@/components/Common/LoadingIndicator'
describe('LoadingIndicator.vue', () => {
it('Calculates animation class', async () => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import Logs from '@/components/Logs'
import Logs from '@/components/Common/Logs'
import { nextTick } from 'vue'
let place

View File

@@ -1,7 +1,7 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount } from '@vue/test-utils'
import Pager from '@/components/SqlTable/Pager'
import Pager from '@/components/Common/Pager'
describe('Pager.vue', () => {
afterEach(() => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import Splitpanes from '@/components/Splitpanes'
import Splitpanes from '@/components/Common/Splitpanes'
import { nextTick } from 'vue'
describe('Splitpanes.vue', () => {
@@ -49,6 +49,41 @@ describe('Splitpanes.vue', () => {
).to.equal('40%')
})
it('renders correctly with hidden panels', async () => {
// mount the component
const wrapper = shallowMount(Splitpanes, {
attachTo: document.body,
slots: {
leftPane: '<div />',
rightPane: '<div />'
},
props: {
before: { size: 60, max: 100, hidden: true },
after: { size: 40, max: 100 },
horizontal: true
}
})
expect(wrapper.findAll('.splitpanes-pane')[0].isVisible()).to.equal(false)
expect(wrapper.find('.splitpanes-splitter').isVisible()).to.equal(false)
expect(
wrapper.findAll('.splitpanes-pane')[1].element.style.height
).to.equal('100%')
await wrapper.setProps({
before: { size: 60, max: 100 },
after: { size: 40, max: 100, hidden: true }
})
expect(wrapper.findAll('.splitpanes-pane')[1].isVisible()).to.equal(false)
expect(wrapper.find('.splitpanes-splitter').isVisible()).to.equal(false)
expect(
wrapper.findAll('.splitpanes-pane')[0].element.style.height
).to.equal('100%')
wrapper.unmount()
})
it('toggles correctly - no maximized initially', async () => {
// mount the component
const wrapper = shallowMount(Splitpanes, {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import sinon from 'sinon'
import splitter from '@/components/Splitpanes/splitter'
import splitter from '@/components/Common/Splitpanes/splitter'
describe('splitter.js', () => {
afterEach(() => {

View File

@@ -1,8 +1,9 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView'
import DataView from '@/components/DataView.vue'
import sinon from 'sinon'
import { nextTick } from 'vue'
import cIo from '@/lib/utils/clipboardIo'
describe('DataView.vue', () => {
const $store = { state: { isWorkspaceVisible: true } }
@@ -14,7 +15,7 @@ describe('DataView.vue', () => {
it('emits update on mode changing', async () => {
const wrapper = mount(DataView, {
global: {
stubs: { chart: true }
mocks: { $store }
}
})
@@ -28,7 +29,10 @@ describe('DataView.vue', () => {
it('method getOptionsForSave calls the same method of the current view component', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store }
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
@@ -52,19 +56,34 @@ 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 }
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
// Find chart and spy the method
const chart = wrapper.findComponent({ name: 'Chart' }).vm
sinon.spy(chart, 'saveAsSvg')
sinon.stub(chart, 'saveAsSvg')
// Export to svg
const svgBtn = wrapper.findComponent({ ref: 'svgExportBtn' })
@@ -77,7 +96,7 @@ describe('DataView.vue', () => {
// Find pivot and spy the method
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsSvg')
sinon.stub(pivot, 'saveAsSvg')
// Switch to Custom Chart renderer
pivot.pivotOptions.rendererName = 'Custom chart'
@@ -86,13 +105,22 @@ 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 }
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
@@ -113,9 +141,76 @@ describe('DataView.vue', () => {
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsHtml')
// Export to svg
// Export to html
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()
})
@@ -146,6 +241,7 @@ describe('DataView.vue', () => {
it('copy to clipboard more than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'write').resolves()
sinon.stub(cIo, 'copyImage')
const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, {
attachTo: document.body,
@@ -165,7 +261,7 @@ describe('DataView.vue', () => {
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Copy to clipboard'
)
@@ -180,11 +276,10 @@ describe('DataView.vue', () => {
// Wait untill prepareCopy is finished
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
await nextTick()
await nextTick()
// The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
// ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('Image is ready')
@@ -196,12 +291,13 @@ describe('DataView.vue', () => {
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
wrapper.unmount()
})
it('copy to clipboard less than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'write').resolves()
sinon.stub(cIo, 'copyImage')
const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, {
attachTo: document.body,
@@ -226,10 +322,9 @@ 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').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount()
@@ -270,9 +365,105 @@ describe('DataView.vue', () => {
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount()
})
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' })
})
it('switches visibility of node or edge in graph mode', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
// viewNodeOrEdgeBtn is not disaplyed in chart mode
expect(
wrapper.findComponent({ ref: 'viewNodeOrEdgeBtn' }).exists()
).to.equal(false)
// Switch to pivot
const pivotBtn = wrapper.findComponent({ ref: 'pivotBtn' })
await pivotBtn.trigger('click')
// viewNodeOrEdgeBtn is not disaplyed in pivot mode
expect(
wrapper.findComponent({ ref: 'viewNodeOrEdgeBtn' }).exists()
).to.equal(false)
// Switch to graph
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
// viewNodeOrEdgeBtn is disaplyed in graph mode
const viewNodeOrEdgeBtn = wrapper.findComponent({
ref: 'viewNodeOrEdgeBtn'
})
expect(viewNodeOrEdgeBtn.exists()).to.equal(true)
// by default node viewer is hidden
expect(wrapper.findComponent({ name: 'value-viewer' }).exists()).to.equal(
false
)
// Click to show node viewer
await viewNodeOrEdgeBtn.trigger('click')
expect(wrapper.findComponent({ name: 'value-viewer' }).exists()).to.equal(
true
)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,825 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount, flushPromises } from '@vue/test-utils'
import Graph from '@/components/Graph/index.vue'
import { nextTick } from '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('the graph resizes when node viewer visibillity togglles', 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
}
},
showValueViewer: false
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const canvas = wrapper.find('canvas.sigma-nodes').wrapperElement
const initialCanvasWidth = canvas.scrollWidth
await wrapper.setProps({ showValueViewer: true })
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialCanvasWidth)
await wrapper.setProps({ showValueViewer: false })
await flushPromises()
expect(canvas.scrollWidth).to.equal(initialCanvasWidth)
wrapper.unmount()
})
it('the graph resizes when the split pane 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
}
},
showValueViewer: true
},
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 initialCanvasWidth = canvas.scrollWidth
await wrapper.find('.splitpanes-splitter').trigger('mousedown')
document.dispatchEvent(
new MouseEvent('mousemove', {
clientX: initialContainerWidth / 2,
clientY: 80
})
)
document.dispatchEvent(new MouseEvent('mouseup'))
await nextTick()
await nextTick()
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialCanvasWidth)
wrapper.unmount()
})
it('opens and closes node viewer', 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
}
},
showValueViewer: false
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
expect(wrapper.text()).not.contain('No node or edge selected to view')
await wrapper.setProps({ showValueViewer: true })
expect(wrapper.text()).contains('No node or edge selected to view')
})
it('passes selected item to node viewer', 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
}
},
showValueViewer: true
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
expect(wrapper.find('.value-viewer .value-body').exists()).to.equal(false)
await wrapper
.findComponent({ ref: 'graphEditor' })
.vm.$emit('selectItem', { object_type: 0, node_id: 'Gryffindor' })
expect(wrapper.find('.value-viewer .value-body').text()).contains(
'"object_type": 0,'
)
expect(wrapper.find('.value-viewer .value-body').text()).contains(
'"node_id": "Gryffindor"'
)
await wrapper
.findComponent({ ref: 'graphEditor' })
.vm.$emit('clearSelection')
expect(wrapper.find('.value-viewer .value-body').exists()).to.equal(false)
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()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,12 @@ import { expect } from 'chai'
import sinon from 'sinon'
import { mount, shallowMount } from '@vue/test-utils'
import { createStore } from 'vuex'
import MainMenu from '@/views/MainView/MainMenu'
import MainMenu from '@/components/MainMenu'
import storedInquiries from '@/lib/storedInquiries'
import { nextTick } from 'vue'
import eventBus from '@/lib/eventBus'
import actions from '@/store/actions'
import mutations from '@/store/mutations'
let wrapper = null
@@ -26,7 +28,7 @@ describe('MainMenu.vue', () => {
wrapper.unmount()
})
it('Create and Save are visible only on /workspace page', async () => {
it('Create, Save and Save as are visible only on /workspace page', async () => {
const state = {
currentTab: { query: '', execute: sinon.stub() },
tabs: [{}],
@@ -45,6 +47,8 @@ describe('MainMenu.vue', () => {
})
expect(wrapper.find('#save-btn').exists()).to.equal(true)
expect(wrapper.find('#save-btn').isVisible()).to.equal(true)
expect(wrapper.find('#save-as-btn').exists()).to.equal(true)
expect(wrapper.find('#save-as-btn').isVisible()).to.equal(true)
expect(wrapper.find('#create-btn').exists()).to.equal(true)
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
wrapper.unmount()
@@ -65,7 +69,7 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
})
it('Save is not visible if there is no tabs', () => {
it('Save and Save as are not visible if there is no tabs', () => {
const state = {
currentTab: null,
tabs: [],
@@ -83,6 +87,8 @@ describe('MainMenu.vue', () => {
})
expect(wrapper.find('#save-btn').exists()).to.equal(true)
expect(wrapper.find('#save-btn').isVisible()).to.equal(false)
expect(wrapper.find('#save-as-btn').exists()).to.equal(true)
expect(wrapper.find('#save-as-btn').isVisible()).to.equal(false)
expect(wrapper.find('#create-btn').exists()).to.equal(true)
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
})
@@ -111,10 +117,12 @@ describe('MainMenu.vue', () => {
})
const vm = wrapper.vm
expect(wrapper.find('#save-btn').element.disabled).to.equal(false)
expect(wrapper.find('#save-as-btn').element.disabled).to.equal(false)
store.state.tabs[0].isSaved = true
await vm.$nextTick()
expect(wrapper.find('#save-btn').element.disabled).to.equal(true)
expect(wrapper.find('#save-as-btn').element.disabled).to.equal(false)
})
it('Creates a tab', async () => {
@@ -332,7 +340,7 @@ describe('MainMenu.vue', () => {
expect(wrapper.vm.createNewInquiry.callCount).to.equal(4)
})
it('Ctrl S calls checkInquiryBeforeSave if the tab is unsaved and route path is /workspace', async () => {
it('Ctrl S calls onSave if the tab is unsaved and route path is /workspace', async () => {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
@@ -353,36 +361,34 @@ describe('MainMenu.vue', () => {
plugins: [store]
}
})
sinon.stub(wrapper.vm, 'checkInquiryBeforeSave')
sinon.stub(wrapper.vm, 'onSave')
const ctrlS = new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
const metaS = new KeyboardEvent('keydown', { key: 's', metaKey: true })
// tab is unsaved and route is /workspace
document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledOnce).to.equal(true)
expect(wrapper.vm.onSave.calledOnce).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
// tab is saved and route is /workspace
store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
// tab is unsaved and route is not /workspace
wrapper.vm.$route.path = '/inquiries'
store.state.tabs[0].isSaved = false
document.dispatchEvent(ctrlS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
})
it('Saves the inquiry when no need the new name', async () => {
it('Ctrl Shift S calls onSaveAs if route path is /workspace', async () => {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
@@ -392,6 +398,81 @@ describe('MainMenu.vue', () => {
tabs: [tab],
db: {}
}
const store = createStore({ state })
const $route = { path: '/workspace' }
wrapper = shallowMount(MainMenu, {
global: {
mocks: { $route },
stubs: ['router-link'],
plugins: [store]
}
})
sinon.stub(wrapper.vm, 'onSaveAs')
const ctrlS = new KeyboardEvent('keydown', {
key: 'S',
ctrlKey: true,
shiftKey: true
})
const metaS = new KeyboardEvent('keydown', {
key: 'S',
metaKey: true,
shiftKey: true
})
// tab is unsaved and route is /workspace
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.calledOnce).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.calledTwice).to.equal(true)
// tab is saved and route is /workspace
store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.calledThrice).to.equal(true)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
// tab is unsaved and route is not /workspace
wrapper.vm.$route.path = '/inquiries'
store.state.tabs[0].isSaved = false
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
// tab is saved and route is not /workspace
wrapper.vm.$route.path = '/inquiries'
store.state.tabs[0].isSaved = true
document.dispatchEvent(ctrlS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
document.dispatchEvent(metaS)
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
})
it('Saves the inquiry when no need the new name and no update conflict', async () => {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: tab,
inquiries: [
{
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
updatedAt: '2025-05-15T15:30:00Z',
createdAt: '2025-05-14T15:30:00Z'
}
],
tabs: [tab],
db: {}
}
const mutations = {
updateTab: sinon.stub()
}
@@ -401,7 +482,8 @@ describe('MainMenu.vue', () => {
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -446,7 +528,8 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
isSaved: true,
updatedAt: '2025-05-16T15:30:00Z'
}
})
)
@@ -456,6 +539,396 @@ 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,
@@ -463,7 +936,8 @@ describe('MainMenu.vue', () => {
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
isSaved: false,
updatedAt: '2025-05-15T15:30:00Z'
}
const state = {
currentTab: tab,
@@ -479,7 +953,8 @@ describe('MainMenu.vue', () => {
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-16T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -522,14 +997,15 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
})
it('Saves the inquiry with a new name', async () => {
it('Saves the new inquiry with a new name', async () => {
const tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
isSaved: false,
updatedAt: undefined
}
const state = {
currentTab: tab,
@@ -542,10 +1018,11 @@ describe('MainMenu.vue', () => {
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo',
id: 1,
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -583,8 +1060,6 @@ 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)
@@ -604,11 +1079,12 @@ describe('MainMenu.vue', () => {
tab,
newValues: {
name: 'foo',
id: 1,
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
}
})
)
@@ -650,7 +1126,8 @@ describe('MainMenu.vue', () => {
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
viewOptions: [],
updatedAt: '2025-05-15T15:30:00Z'
})
}
const store = createStore({ state, mutations, actions })
@@ -691,8 +1168,6 @@ 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)
@@ -716,7 +1191,8 @@ describe('MainMenu.vue', () => {
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
isSaved: true,
updatedAt: '2025-05-15T15:30:00Z'
}
})
)
@@ -724,19 +1200,6 @@ 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 () => {
@@ -761,7 +1224,7 @@ describe('MainMenu.vue', () => {
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
chart: []
viewType: 'chart'
})
}
const store = createStore({ state, mutations, actions })
@@ -809,4 +1272,110 @@ 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

@@ -1,11 +1,11 @@
import { expect } from 'chai'
import { mount, flushPromises } from '@vue/test-utils'
import Pivot from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot'
import Pivot from '@/components/Pivot/index.vue'
import chartHelper from '@/lib/chartHelper'
import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
import sinon from 'sinon'
import pivotHelper from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/pivotHelper'
import pivotHelper from '@/components/Pivot/pivotHelper'
describe('Pivot.vue', () => {
let container
@@ -586,4 +586,48 @@ 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

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import PivotSortBtn from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/PivotUi/PivotSortBtn'
import PivotSortBtn from '@/components/Pivot/PivotUi/PivotSortBtn'
describe('PivotSortBtn.vue', () => {
it('switches order', async () => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import PivotUi from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/PivotUi'
import PivotUi from '@/components/Pivot/PivotUi'
describe('PivotUi.vue', () => {
it('returns value when settings changed', async () => {

View File

@@ -3,7 +3,7 @@ import {
_getDataSources,
getPivotCanvas,
getPivotHtml
} from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/pivotHelper'
} from '@/components/Pivot/pivotHelper'
describe('pivotHelper.js', () => {
it('_getDataSources returns data sources', () => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Record from '@/views/MainView/Workspace/Tabs/Tab/RunResult/Record'
import Record from '@/components/RunResult/Record'
describe('Record.vue', () => {
it('shows record with selected cell', async () => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import RunResult from '@/views/MainView/Workspace/Tabs/Tab/RunResult'
import RunResult from '@/components/RunResult'
import csv from '@/lib/csv'
import sinon from 'sinon'
import { nextTick } from 'vue'
@@ -18,7 +18,6 @@ describe('RunResult.vue', () => {
sinon.spy(window, 'alert')
const wrapper = mount(RunResult, {
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -54,7 +53,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -78,7 +76,7 @@ describe('RunResult.vue', () => {
await nextTick()
// The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
'Copy to clipboard'
)
@@ -91,7 +89,7 @@ describe('RunResult.vue', () => {
await nextTick()
// The dialog is shown...
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
// ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
@@ -104,7 +102,7 @@ describe('RunResult.vue', () => {
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
wrapper.unmount()
})
@@ -114,7 +112,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -139,11 +136,10 @@ 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').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
wrapper.unmount()
@@ -155,7 +151,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -188,7 +183,7 @@ describe('RunResult.vue', () => {
.trigger('click')
// The dialog is not shown...
await clock.tick(100)
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount()
@@ -197,7 +192,6 @@ describe('RunResult.vue', () => {
it('shows value of selected cell - result set', async () => {
const wrapper = mount(RunResult, {
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -251,9 +245,9 @@ describe('RunResult.vue', () => {
// Click on 'bar' cell again
await rows[1].findAll('td')[1].trigger('click')
expect(
wrapper.find('.value-viewer-container .table-preview').text()
).to.equals('No cell selected to view')
expect(wrapper.find('.value-viewer').text()).to.equals(
'No cell selected to view'
)
wrapper.unmount()
})
@@ -261,7 +255,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -324,9 +317,9 @@ describe('RunResult.vue', () => {
// Click on 'foo' cell again
await rows[1].find('td').trigger('click')
expect(
wrapper.find('.value-viewer-container .table-preview').text()
).to.equals('No cell selected to view')
expect(wrapper.find('.value-viewer').text()).to.equals(
'No cell selected to view'
)
wrapper.unmount()
})
@@ -334,7 +327,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {

View File

@@ -4,8 +4,8 @@ import { mount } from '@vue/test-utils'
import { createStore } from 'vuex'
import actions from '@/store/actions'
import mutations from '@/store/mutations'
import Schema from '@/views/MainView/Workspace/Schema'
import TableDescription from '@/views/MainView/Workspace/Schema/TableDescription'
import Schema from '@/components/Schema'
import TableDescription from '@/components/Schema/TableDescription'
import database from '@/lib/database'
import fIo from '@/lib/utils/fileIo'
import csv from '@/lib/csv'

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import TableDescription from '@/views/MainView/Workspace/Schema/TableDescription'
import TableDescription from '@/components/Schema/TableDescription'
describe('TableDescription.vue', () => {
it('Initially the columns are hidden and table name is rendered', () => {

View File

@@ -1,7 +1,7 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import { createStore } from 'vuex'
import SqlEditor from '@/views/MainView/Workspace/Tabs/Tab/SqlEditor'
import SqlEditor from '@/components/SqlEditor'
import { nextTick } from 'vue'
describe('SqlEditor.vue', () => {

View File

@@ -1,9 +1,7 @@
import { expect } from 'chai'
import sinon from 'sinon'
import state from '@/store/state'
import showHint, {
getHints
} from '@/views/MainView/Workspace/Tabs/Tab/SqlEditor/hint'
import showHint, { getHints } from '@/components/SqlEditor/hint'
import CM from 'codemirror'
describe('hint.js', () => {

View File

@@ -3,7 +3,7 @@ import sinon from 'sinon'
import { mount } from '@vue/test-utils'
import mutations from '@/store/mutations'
import { createStore } from 'vuex'
import Tab from '@/views/MainView/Workspace/Tabs/Tab'
import Tab from '@/components/Tab'
import { nextTick } from 'vue'
let place

View File

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

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import ValueViewer from '@/views/MainView/Workspace/Tabs/Tab/RunResult/ValueViewer.vue'
import ValueViewer from '@/components/ValueViewer.vue'
import sinon from 'sinon'
describe('ValueViewer.vue', () => {
@@ -11,28 +11,45 @@ describe('ValueViewer.vue', () => {
it('shows value in text mode', () => {
const wrapper = mount(ValueViewer, {
props: {
cellValue: 'foo'
value: 'foo'
}
})
expect(wrapper.find('.value-body').text()).to.equals('foo')
expect(wrapper.find('button.text').attributes('aria-selected')).to.equal(
'true'
)
})
it('shows meta values', async () => {
const wrapper = mount(ValueViewer, {
props: {
value: new Uint8Array()
}
})
expect(wrapper.find('.value-body').text()).to.equals('BLOB')
await wrapper.setProps({ value: null })
expect(wrapper.find('.value-body').text()).to.equals('NULL')
})
it('shows error in json mode if the value is not json', async () => {
const wrapper = mount(ValueViewer, {
props: {
cellValue: 'foo'
value: 'foo',
defaultFormat: 'json'
}
})
await wrapper.find('button.json').trigger('click')
expect(wrapper.find('.value-body').text()).to.equals("Can't parse JSON.")
expect(wrapper.find('button[aria-selected="true"]').text()).contains('JSON')
})
it('copy to clipboard', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const wrapper = mount(ValueViewer, {
props: {
cellValue: 'foo'
value: 'foo'
}
})
@@ -41,13 +58,20 @@ describe('ValueViewer.vue', () => {
expect(window.navigator.clipboard.writeText.calledOnceWith('foo')).to.equal(
true
)
await wrapper.setProps({ value: '{"foo": "bar"}' })
await wrapper.find('button.json').trigger('click')
await wrapper.find('button.copy').trigger('click')
expect(window.navigator.clipboard.writeText.args[1][0]).to.equal(
'{\n "foo": "bar"\n}'
)
})
it('wraps lines', async () => {
const wrapper = mount(ValueViewer, {
attachTo: document.body,
props: {
cellValue: 'foo'
value: 'foo'
}
})
@@ -55,7 +79,7 @@ describe('ValueViewer.vue', () => {
const valueBody = wrapper.find('.value-body').wrapperElement
expect(valueBody.scrollWidth).to.equal(valueBody.clientWidth)
await wrapper.setProps({ cellValue: 'foo'.repeat(100) })
await wrapper.setProps({ value: 'foo'.repeat(100) })
expect(valueBody.scrollWidth).not.to.equal(valueBody.clientWidth)
await wrapper.find('button.line-wrap').trigger('click')
@@ -67,7 +91,7 @@ describe('ValueViewer.vue', () => {
const wrapper = mount(ValueViewer, {
attachTo: document.body,
props: {
cellValue: '{"foo": "foofoofoofoofoofoofoofoofoofoo"}'
value: '{"foo": "foofoofoofoofoofoofoofoofoofoo"}'
}
})
@@ -83,4 +107,15 @@ describe('ValueViewer.vue', () => {
expect(codeMirrorScroll.scrollWidth).to.equal(codeMirrorScroll.clientWidth)
wrapper.unmount()
})
it('shows empty message if empty is true', () => {
const wrapper = mount(ValueViewer, {
props: {
empty: true,
emptyMessage: 'I am empty'
}
})
expect(wrapper.find('.value-viewer').text()).to.equals('I am empty')
})
})

View File

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

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More