mirror of
https://github.com/lana-k/sqliteviz.git
synced 2026-08-05 23:39:17 +08:00
Compare commits
76 Commits
0a8c09b58d
...
0.31.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529c3390b3 | ||
|
|
acea6090e8 | ||
|
|
130996b8c1 | ||
|
|
6074ccc835 | ||
|
|
62786e7dda | ||
|
|
6fbddb69bb | ||
|
|
9838421d65 | ||
|
|
686a09a8ce | ||
|
|
4e9d1a785c | ||
|
|
2b6de12dc5 | ||
|
|
39d1604959 | ||
|
|
053b8fbecb | ||
|
|
1043a03c96 | ||
|
|
b6ec71338b | ||
|
|
4bb6676648 | ||
|
|
f3a75346fd | ||
|
|
366ffcff10 | ||
|
|
778db0d9eb | ||
|
|
c00d76904e | ||
|
|
4cc6ec86ea | ||
|
|
c0d972c2ab | ||
|
|
d9435a80c3 | ||
|
|
62fb92d824 | ||
|
|
5019013eff | ||
|
|
c2c376219f | ||
|
|
0199415dde | ||
|
|
534b186d76 | ||
|
|
4f6efb5bda | ||
|
|
2c0b8f9124 | ||
|
|
5265f5493e | ||
|
|
c0e59f6fb8 | ||
|
|
7471744633 | ||
|
|
e6e5efa8c6 | ||
|
|
57c36b3900 | ||
|
|
1e8c1761e6 | ||
|
|
dd30e17ff5 | ||
|
|
4e5adc147f | ||
|
|
7edc196a02 | ||
|
|
85b5a200e2 | ||
|
|
a0ef93921f | ||
|
|
859cd2ccfc | ||
|
|
a59946c09d | ||
|
|
7b06b3d9c8 | ||
|
|
ced933f497 | ||
|
|
cda368f109 | ||
|
|
df67466c2f | ||
|
|
528549ae5a | ||
|
|
20f4dcc645 | ||
|
|
b8353ef0ce | ||
|
|
7975f419c9 | ||
|
|
72aa0dd80b | ||
|
|
e000ee71fc | ||
|
|
b6a12668d3 | ||
|
|
713f5ac768 | ||
|
|
5492609c3a | ||
|
|
8bfd0f5944 | ||
|
|
a8006bcf52 | ||
|
|
1463f93bb0 | ||
|
|
5108495430 | ||
|
|
d28968e539 | ||
|
|
68221cba6d | ||
|
|
65c1c18fcb | ||
|
|
d7db6a0f5d | ||
|
|
0a2af0bba3 | ||
|
|
e4b35bac0a | ||
|
|
3d1e822cdc | ||
|
|
3d6479be7a | ||
|
|
218ab52ab3 | ||
|
|
f178937440 | ||
|
|
411bd694c0 | ||
|
|
d2969de127 | ||
|
|
b59c21c14e | ||
|
|
4ed4b54a28 | ||
|
|
2c2bb7d6d3 | ||
|
|
efbd985b36 | ||
|
|
9cf7d0e5dc |
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -23,7 +23,10 @@ jobs:
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo add-apt-repository -y ppa:mozillateam/ppa
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y chromium-browser firefox-esr
|
||||
sudo apt-get install -y \
|
||||
chromium-browser \
|
||||
firefox-esr \
|
||||
xvfb
|
||||
|
||||
- name: Update npm
|
||||
run: npm install -g npm@10
|
||||
@@ -35,4 +38,4 @@ jobs:
|
||||
run: npm run lint -- --no-fix
|
||||
|
||||
- name: Run karma tests
|
||||
run: npm run test
|
||||
run: xvfb-run -a npm test
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
# docker build -t sqliteviz/test -f Dockerfile.test .
|
||||
#
|
||||
|
||||
FROM node:12.22-bullseye
|
||||
FROM node:18.20.8-bookworm
|
||||
|
||||
RUN set -ex; \
|
||||
apt update; \
|
||||
apt install -y chromium firefox-esr; \
|
||||
npm install -g npm@7
|
||||
npm install -g npm@10
|
||||
|
||||
WORKDIR /tmp/build
|
||||
|
||||
@@ -19,6 +19,6 @@ RUN npm install
|
||||
COPY . .
|
||||
|
||||
RUN set -ex; \
|
||||
sed -i 's/browsers: \[.*\],/browsers: ['"'FirefoxHeadlessTouch'"'],/' karma.conf.js
|
||||
sed -i 's/browsers: \[.*\],/browsers: ['"'FirefoxHeadlessTouch'"'],/' karma.conf.cjs
|
||||
|
||||
RUN npm run lint -- --no-fix && npm run test
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@*": ["./src/*"]
|
||||
"@\/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
6956
package-lock.json
generated
6956
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sqliteviz",
|
||||
"version": "0.27.0",
|
||||
"version": "0.31.0",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -10,26 +10,34 @@
|
||||
"serve": "vite preview",
|
||||
"test": "karma start karma.conf.cjs",
|
||||
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src",
|
||||
"format": "prettier . --write"
|
||||
"format": "prettier . --write",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"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",
|
||||
"papaparse": "^5.4.1",
|
||||
"pivottable": "^2.23.0",
|
||||
"plotly.js": "^2.35.2",
|
||||
"plotly.js": "^2.35.3",
|
||||
"promise-worker": "^2.0.1",
|
||||
"react": "^16.14.0",
|
||||
"react-chart-editor": "^0.46.1",
|
||||
"react-dom": "^16.14.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sigma": "3.0.2",
|
||||
"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",
|
||||
@@ -61,6 +69,8 @@
|
||||
"karma-spec-reporter": "^0.0.36",
|
||||
"karma-vite": "^1.0.5",
|
||||
"mocha": "^5.2.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"process": "^0.11.10",
|
||||
"url-loader": "^4.1.1",
|
||||
|
||||
26
patches/plotly.js+2.35.3.patch
Normal file
26
patches/plotly.js+2.35.3.patch
Normal file
@@ -0,0 +1,26 @@
|
||||
diff --git a/node_modules/plotly.js/dist/plotly.js b/node_modules/plotly.js/dist/plotly.js
|
||||
index 28f5612..5b9956d 100644
|
||||
--- a/node_modules/plotly.js/dist/plotly.js
|
||||
+++ b/node_modules/plotly.js/dist/plotly.js
|
||||
@@ -34384,6 +34384,9 @@ var layoutUIControlPatterns = [{
|
||||
pattern: /^(shapes|annotations)/,
|
||||
attr: 'editrevision'
|
||||
}, {
|
||||
+ pattern: /^selections/,
|
||||
+ attr: 'selectionrevision'
|
||||
+},{
|
||||
pattern: /^title\.text$/,
|
||||
attr: 'editrevision'
|
||||
}];
|
||||
diff --git a/node_modules/plotly.js/src/plot_api/plot_api.js b/node_modules/plotly.js/src/plot_api/plot_api.js
|
||||
index 75e98f5..5c38053 100644
|
||||
--- a/node_modules/plotly.js/src/plot_api/plot_api.js
|
||||
+++ b/node_modules/plotly.js/src/plot_api/plot_api.js
|
||||
@@ -2394,6 +2394,7 @@ var layoutUIControlPatterns = [
|
||||
|
||||
{pattern: /^legend\.(x|y)$/, attr: 'editrevision'},
|
||||
{pattern: /^(shapes|annotations)/, attr: 'editrevision'},
|
||||
+ {pattern: /^selections/, attr: 'selectionrevision'},
|
||||
{pattern: /^title\.text$/, attr: 'editrevision'}
|
||||
];
|
||||
|
||||
24
patches/sigma+3.0.2.patch
Normal file
24
patches/sigma+3.0.2.patch
Normal file
@@ -0,0 +1,24 @@
|
||||
diff --git a/node_modules/sigma/dist/colors-beb06eb2.esm.js b/node_modules/sigma/dist/colors-beb06eb2.esm.js
|
||||
index b7130d1..bf101b6 100644
|
||||
--- a/node_modules/sigma/dist/colors-beb06eb2.esm.js
|
||||
+++ b/node_modules/sigma/dist/colors-beb06eb2.esm.js
|
||||
@@ -51,7 +51,6 @@ function _nonIterableRest() {
|
||||
function _slicedToArray(r, e) {
|
||||
return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();
|
||||
}
|
||||
-
|
||||
var HTML_COLORS = {
|
||||
black: "#000000",
|
||||
silver: "#C0C0C0",
|
||||
@@ -267,7 +266,10 @@ for (var htmlColor in HTML_COLORS) {
|
||||
FLOAT_COLOR_CACHE[HTML_COLORS[htmlColor]] = FLOAT_COLOR_CACHE[htmlColor];
|
||||
}
|
||||
function rgbaToFloat(r, g, b, a, masking) {
|
||||
- INT32[0] = a << 24 | b << 16 | g << 8 | r;
|
||||
+ const r_= Math.floor(r * a / 255)
|
||||
+ const g_= Math.floor(g * a / 255)
|
||||
+ const b_= Math.floor(b * a / 255)
|
||||
+ INT32[0] = a << 24 | b_ << 16 | g_ << 8 | r_;
|
||||
if (masking) INT32[0] = INT32[0] & 0xfeffffff;
|
||||
return FLOAT32[0];
|
||||
}
|
||||
42
src/App.vue
42
src/App.vue
@@ -33,48 +33,6 @@ export default {
|
||||
</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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
45
src/assets/styles/typography.css
Normal file
45
src/assets/styles/typography.css
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
@@ -1,29 +1,45 @@
|
||||
<template>
|
||||
<div ref="chartContainer" class="chart-container">
|
||||
<div v-show="!dataSources" class="warning chart-warning">
|
||||
<div class="chart-container">
|
||||
<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>
|
||||
<div
|
||||
<splitpanes
|
||||
:before="{ size: 70, max: 100 }"
|
||||
:after="{ size: 30, max: 50, hidden: !showValueViewer }"
|
||||
:default="{ before: 70, after: 30 }"
|
||||
class="chart"
|
||||
:style="{ height: !dataSources ? 'calc(100% - 40px)' : '100%' }"
|
||||
>
|
||||
<PlotlyEditor
|
||||
ref="plotlyEditor"
|
||||
:data="state.data"
|
||||
:layout="state.layout"
|
||||
:frames="state.frames"
|
||||
:config="config"
|
||||
:dataSources="dataSources"
|
||||
:dataSourceOptions="dataSourceOptions"
|
||||
:plotly="plotly"
|
||||
:useResizeHandler="useResizeHandler"
|
||||
:debug="true"
|
||||
:advancedTraceTypeSelector="true"
|
||||
@update="update"
|
||||
@render="onRender"
|
||||
/>
|
||||
</div>
|
||||
<template #left-pane>
|
||||
<div ref="chartContainer" :style="{ height: '100%' }">
|
||||
<PlotlyEditor
|
||||
ref="plotlyEditor"
|
||||
:data="state.data"
|
||||
:layout="state.layout"
|
||||
:frames="state.frames"
|
||||
:config="config"
|
||||
:dataSources="dataSources"
|
||||
:dataSourceOptions="dataSourceOptions"
|
||||
:plotly="plotly"
|
||||
:useResizeHandler="useResizeHandler"
|
||||
:debug="true"
|
||||
:advancedTraceTypeSelector="true"
|
||||
:hideControls="!showViewSettings"
|
||||
@update="update"
|
||||
@render="onRender"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showValueViewer" #right-pane>
|
||||
<value-viewer
|
||||
:empty="!selectedItems || selectedItems.length === 0"
|
||||
emptyMessage="No points selected to view"
|
||||
:value="JSON.stringify(selectedItems)"
|
||||
defaultFormat="json"
|
||||
/>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,35 +52,50 @@ import chartHelper from '@/lib/chartHelper'
|
||||
import * as dereference from 'react-chart-editor/lib/lib/dereference'
|
||||
import fIo from '@/lib/utils/fileIo'
|
||||
import events from '@/lib/utils/events'
|
||||
import Splitpanes from '@/components/Common/Splitpanes'
|
||||
import ValueViewer from '@/components/ValueViewer.vue'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'Chart',
|
||||
components: {
|
||||
PlotlyEditor: applyPureReactInVue(ReactPlotlyEditorWithPlotRef)
|
||||
PlotlyEditor: applyPureReactInVue(ReactPlotlyEditorWithPlotRef),
|
||||
Splitpanes,
|
||||
ValueViewer
|
||||
},
|
||||
props: {
|
||||
dataSources: Object,
|
||||
initOptions: Object,
|
||||
importToPngEnabled: Boolean,
|
||||
importToSvgEnabled: Boolean,
|
||||
forPivot: Boolean
|
||||
exportToPngEnabled: Boolean,
|
||||
exportToSvgEnabled: Boolean,
|
||||
forPivot: Boolean,
|
||||
showViewSettings: Boolean,
|
||||
showValueViewer: Boolean
|
||||
},
|
||||
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
|
||||
emits: [
|
||||
'update:exportToSvgEnabled',
|
||||
'update:exportToHtmlEnabled',
|
||||
'update',
|
||||
'loadingImageCompleted'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
plotly,
|
||||
state: this.initOptions || {
|
||||
data: [],
|
||||
layout: { autosize: true },
|
||||
frames: []
|
||||
},
|
||||
state: this.initOptions
|
||||
? JSON.parse(JSON.stringify(this.initOptions))
|
||||
: {
|
||||
data: [],
|
||||
layout: { autosize: true },
|
||||
frames: []
|
||||
},
|
||||
config: {
|
||||
editable: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['toImage']
|
||||
},
|
||||
resizeObserver: null,
|
||||
useResizeHandler: this.$store.state.isWorkspaceVisible
|
||||
useResizeHandler: this.$store.state.isWorkspaceVisible,
|
||||
selectedItems: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -77,9 +108,13 @@ export default {
|
||||
// we need to update state.data in order to update the graph
|
||||
// https://github.com/plotly/react-chart-editor/issues/948
|
||||
if (this.dataSources) {
|
||||
chartHelper.clearSelection(this.state.data, this.state.layout)
|
||||
dereference.default(this.state.data, this.dataSources)
|
||||
this.updatePlotly()
|
||||
}
|
||||
},
|
||||
showViewSettings() {
|
||||
this.handleResize()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
@@ -102,15 +137,27 @@ export default {
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
this.$emit('update:importToSvgEnabled', true)
|
||||
this.$emit('update:exportToSvgEnabled', true)
|
||||
this.$emit('update:exportToHtmlEnabled', true)
|
||||
},
|
||||
mounted() {
|
||||
async 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()
|
||||
await nextTick()
|
||||
const plotlyDiv =
|
||||
this.$refs.plotlyEditor?.$el?.querySelector('.js-plotly-plot')
|
||||
plotlyDiv?.on('plotly_selected', selectionEvent => {
|
||||
if (selectionEvent) {
|
||||
this.selectedItems = this.getSelectedData()
|
||||
}
|
||||
})
|
||||
plotlyDiv?.on('plotly_deselect', () => {
|
||||
this.selectedItems = this.getSelectedData()
|
||||
})
|
||||
},
|
||||
activated() {
|
||||
this.useResizeHandler = true
|
||||
@@ -123,12 +170,18 @@ 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() {
|
||||
// TODO: check changes and enable Save button if needed
|
||||
},
|
||||
update(data, layout, frames) {
|
||||
chartHelper.clearSelection(data, layout)
|
||||
|
||||
this.state = { data, layout, frames }
|
||||
this.$emit('update')
|
||||
},
|
||||
@@ -163,6 +216,12 @@ export default {
|
||||
},
|
||||
prepareCopy(type = 'png') {
|
||||
return chartHelper.getImageDataUrl(this.$refs.plotlyEditor.$el, type)
|
||||
},
|
||||
getSelectedData() {
|
||||
return chartHelper.getRowsByIndexFromDataSources(
|
||||
this.dataSources,
|
||||
chartHelper.getSelectedPointsIndexes(this.state.data)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,13 +232,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;
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<script>
|
||||
import tooltipMixin from '@/tooltipMixin'
|
||||
import LoadingIndicator from '@/components/LoadingIndicator'
|
||||
import LoadingIndicator from '@/components/Common/LoadingIndicator'
|
||||
|
||||
export default {
|
||||
name: 'SideBarButton',
|
||||
@@ -5,7 +5,7 @@
|
||||
:clickToClose="false"
|
||||
:contentTransition="{ name: 'loading-dialog' }"
|
||||
:overlayTransition="{ name: 'loading-dialog' }"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-header">
|
||||
{{ title }}
|
||||
@@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIndicator from '@/components/LoadingIndicator'
|
||||
import LoadingIndicator from '@/components/Common/LoadingIndicator'
|
||||
import CloseIcon from '@/components/svg/close'
|
||||
|
||||
export default {
|
||||
@@ -60,12 +60,12 @@ export default {
|
||||
title: String,
|
||||
loading: Boolean
|
||||
},
|
||||
emits: ['cancel', 'action', 'update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
show: this.modelValue
|
||||
}
|
||||
},
|
||||
emits: ['cancel', 'action', 'update:modelValue'],
|
||||
watch: {
|
||||
modelValue() {
|
||||
this.show = this.modelValue
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIndicator from '@/components/LoadingIndicator'
|
||||
import LoadingIndicator from '@/components/Common/LoadingIndicator'
|
||||
|
||||
export default {
|
||||
name: 'Logs',
|
||||
@@ -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}%`
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -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'
|
||||
|
||||
@@ -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 !== 'pivot'"
|
||||
ref="viewDetailsBtn"
|
||||
tooltip="View 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"
|
||||
@@ -85,18 +125,22 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Chart from './Chart/index.vue'
|
||||
import Pivot from './Pivot/index.vue'
|
||||
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.vue'
|
||||
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,13 +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,
|
||||
showLoadingDialog: false
|
||||
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: {
|
||||
@@ -139,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: {
|
||||
@@ -217,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
|
||||
)
|
||||
@@ -125,6 +125,13 @@ export default {
|
||||
},
|
||||
|
||||
async checkFile(file) {
|
||||
if (!file) {
|
||||
alert(
|
||||
'You should drop a file. ' +
|
||||
'It can be an SQLite database, CSV file, JSON or NDJSON file.'
|
||||
)
|
||||
return
|
||||
}
|
||||
this.state = 'dropping'
|
||||
this.newDb = database.getNewDatabase()
|
||||
|
||||
|
||||
141
src/components/Graph/AdvancedForceAtlasLayoutSettings.vue
Normal file
141
src/components/Graph/AdvancedForceAtlasLayoutSettings.vue
Normal 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>
|
||||
75
src/components/Graph/CirclePackLayoutSettings.vue
Normal file
75
src/components/Graph/CirclePackLayoutSettings.vue
Normal 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" fieldContainerClassName="test_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>
|
||||
148
src/components/Graph/EdgeColorSettings.vue
Normal file
148
src/components/Graph/EdgeColorSettings.vue
Normal 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: 'Reversed', 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>
|
||||
93
src/components/Graph/EdgeSizeSettings.vue
Normal file
93
src/components/Graph/EdgeSizeSettings.vue
Normal 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>
|
||||
46
src/components/Graph/ForceAtlasLayoutSettings.vue
Normal file
46
src/components/Graph/ForceAtlasLayoutSettings.vue
Normal 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>
|
||||
86
src/components/Graph/ForceAtlasSeedLayoutSettings.vue
Normal file
86
src/components/Graph/ForceAtlasSeedLayoutSettings.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<Field label="Initial algorithm">
|
||||
<Dropdown
|
||||
:options="layoutOptions"
|
||||
:value="modelValue.initialAlgorithm"
|
||||
:clearable="false"
|
||||
className="test_fa2_initial_layout_algorithm_select"
|
||||
@change="updateInitialAlgorithm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<component
|
||||
:is="layoutSettingsComponentMap[modelValue.initialAlgorithm]"
|
||||
v-if="modelValue.initialAlgorithm !== 'circular'"
|
||||
:modelValue="modelValue"
|
||||
:keyOptions="keyOptions"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { markRaw } from 'vue'
|
||||
import { applyPureReactInVue } from 'veaury'
|
||||
import Field from 'react-chart-editor/lib/components/fields/Field'
|
||||
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
|
||||
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
|
||||
import 'react-chart-editor/lib/react-chart-editor.css'
|
||||
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue'
|
||||
import RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Field: applyPureReactInVue(Field),
|
||||
Dropdown: applyPureReactInVue(Dropdown),
|
||||
NumericInput: applyPureReactInVue(NumericInput),
|
||||
RandomLayoutSettings,
|
||||
CirclePackLayoutSettings
|
||||
},
|
||||
props: {
|
||||
modelValue: Object,
|
||||
keyOptions: Array
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
layoutOptions: markRaw([
|
||||
{ label: 'Circular', value: 'circular' },
|
||||
{ label: 'Random', value: 'random' },
|
||||
{ label: 'Circle pack', value: 'circlepack' }
|
||||
]),
|
||||
layoutSettingsComponentMap: markRaw({
|
||||
random: RandomLayoutSettings,
|
||||
circlepack: CirclePackLayoutSettings
|
||||
}),
|
||||
defaultSeedLayoutSettings: {
|
||||
circular: { initialAlgorithm: 'circular' },
|
||||
random: { initialAlgorithm: 'random', seedValue: 1 },
|
||||
circlepack: {
|
||||
initialAlgorithm: 'circlepack',
|
||||
hierarchyAttributes: [],
|
||||
seedValue: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateInitialAlgorithm(newAlgorithm) {
|
||||
const newSettings = {
|
||||
...this.modelValue
|
||||
}
|
||||
|
||||
const prevAlgorithmSettings =
|
||||
this.defaultSeedLayoutSettings[this.modelValue.initialAlgorithm]
|
||||
Object.keys(prevAlgorithmSettings).forEach(key => {
|
||||
delete newSettings[key]
|
||||
prevAlgorithmSettings[key] = this.modelValue[key]
|
||||
})
|
||||
|
||||
this.$emit('update:modelValue', {
|
||||
...newSettings,
|
||||
...this.defaultSeedLayoutSettings[newAlgorithm]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
859
src/components/Graph/GraphEditor.vue
Normal file
859
src/components/Graph/GraphEditor.vue
Normal file
@@ -0,0 +1,859 @@
|
||||
<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 0) or edge
|
||||
(value 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="Seed layout">
|
||||
<Field>
|
||||
If you already built a graph using another layout, the initial
|
||||
algorithm doesn't apply unless you restart it.
|
||||
</Field>
|
||||
<ForceAtlasSeedLayoutSettings
|
||||
v-model="settings.layout.options"
|
||||
:keyOptions="keysOptions"
|
||||
@update:model-value="updateLayout(settings.layout.type)"
|
||||
/>
|
||||
</Fold>
|
||||
<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"
|
||||
title="Set the settings to default or previously saved ones."
|
||||
@click="resetFA2LayoutSettings"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="test_fa2_toggle"
|
||||
@click="toggleFA2Layout"
|
||||
>
|
||||
<template #node:icon>
|
||||
<div>
|
||||
<RunIcon v-if="!fa2Running" />
|
||||
<PauseIcon v-else />
|
||||
</div>
|
||||
</template>
|
||||
{{ fa2Running ? 'Pause' : 'Continue' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="test_fa2_restart"
|
||||
title="Clear node coordinates and run the layout algorithm anew."
|
||||
@click="restartFA2Layout"
|
||||
>
|
||||
<template #node:icon>
|
||||
<div>
|
||||
<RestartIcon />
|
||||
</div>
|
||||
</template>
|
||||
Restart
|
||||
</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'
|
||||
import ForceAtlasSeedLayoutSettings from '@/components/Graph/ForceAtlasSeedLayoutSettings.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 RestartIcon from '@/components/svg/restart.vue'
|
||||
import PauseIcon from '@/components/svg/pause.vue'
|
||||
import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image'
|
||||
import {
|
||||
buildNodes,
|
||||
buildEdges,
|
||||
updateNodes,
|
||||
updateEdges,
|
||||
reduceNodes,
|
||||
reduceEdges,
|
||||
clearNodeCoordinates
|
||||
} 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,
|
||||
RestartIcon,
|
||||
PauseIcon,
|
||||
RandomLayoutSettings,
|
||||
CirclePackLayoutSettings,
|
||||
NodeColorSettings,
|
||||
NodeSizeSettings,
|
||||
EdgeSizeSettings,
|
||||
EdgeColorSettings,
|
||||
AdvancedForceAtlasLayoutSettings,
|
||||
ForceAtlasSeedLayoutSettings
|
||||
},
|
||||
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
|
||||
}),
|
||||
layoutMethodMap: markRaw({
|
||||
circular: this.applyCircularLayout,
|
||||
random: this.applyRandomLayout,
|
||||
circlepack: this.applyCirclePackLayout,
|
||||
forceAtlas2: this.applyFA2Layout
|
||||
}),
|
||||
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()
|
||||
}
|
||||
|
||||
this.layoutMethodMap[layoutType]()
|
||||
|
||||
if (layoutType === 'forceAtlas2' && layoutType !== prevLayout) {
|
||||
this.autoRunFA2Layout()
|
||||
}
|
||||
},
|
||||
applyCircularLayout() {
|
||||
circular.assign(this.graph)
|
||||
},
|
||||
applyRandomLayout() {
|
||||
random.assign(this.graph, {
|
||||
rng: seedrandom(this.settings.layout.options.seedValue)
|
||||
})
|
||||
},
|
||||
applyCirclePackLayout() {
|
||||
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)
|
||||
})
|
||||
},
|
||||
applyFA2Layout() {
|
||||
if (
|
||||
!this.graph.someNode(
|
||||
(nodeKey, attributes) =>
|
||||
typeof attributes.x === 'number' && typeof attributes.y === 'number'
|
||||
)
|
||||
) {
|
||||
this.layoutMethodMap[this.settings.layout.options.initialAlgorithm]()
|
||||
}
|
||||
|
||||
const fa2settings = { ...this.settings.layout.options }
|
||||
// delete all custom settings because they can break the algorithm running
|
||||
delete fa2settings.initialAlgorithm
|
||||
delete fa2settings.seedValue
|
||||
delete fa2settings.initialIterationsAmount
|
||||
delete fa2settings.hierarchyAttributes
|
||||
this.fa2Layout = markRaw(
|
||||
new FA2Layout(this.graph, {
|
||||
getEdgeWeight: (_, attr) =>
|
||||
this.settings.layout.options.weightSource
|
||||
? attr.data[this.settings.layout.options.weightSource]
|
||||
: 1,
|
||||
settings: fa2settings
|
||||
})
|
||||
)
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
restartFA2Layout() {
|
||||
if (this.fa2Layout.isRunning()) {
|
||||
this.stopFA2Layout()
|
||||
}
|
||||
this.fa2Layout.kill()
|
||||
clearNodeCoordinates(this.graph)
|
||||
this.applyFA2Layout()
|
||||
this.autoRunFA2Layout()
|
||||
},
|
||||
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 = {
|
||||
initialAlgorithm: 'circular',
|
||||
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;
|
||||
}
|
||||
|
||||
.force-atlas-buttons :deep(.button__icon > div) {
|
||||
padding: 0 3px;
|
||||
}
|
||||
</style>
|
||||
192
src/components/Graph/NodeColorSettings.vue
Normal file
192
src/components/Graph/NodeColorSettings.vue
Normal 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: 'Reversed', 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>
|
||||
120
src/components/Graph/NodeSizeSettings.vue
Normal file
120
src/components/Graph/NodeSizeSettings.vue
Normal 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>
|
||||
34
src/components/Graph/RandomLayoutSettings.vue
Normal file
34
src/components/Graph/RandomLayoutSettings.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Field label="Seed value" fieldContainerClassName="test_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>
|
||||
146
src/components/Graph/index.vue
Normal file
146
src/components/Graph/index.vue
Normal 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>
|
||||
@@ -87,7 +87,7 @@
|
||||
</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'
|
||||
@@ -178,6 +178,7 @@ export default {
|
||||
this.openSaveModal()
|
||||
},
|
||||
openSaveModal() {
|
||||
this.$modal.hide('inquiry-conflict')
|
||||
this.errorMsg = null
|
||||
this.name = ''
|
||||
this.$modal.show('save')
|
||||
@@ -190,8 +191,6 @@ export default {
|
||||
this.saveInquiry()
|
||||
},
|
||||
async saveInquiry() {
|
||||
const dataSet = this.currentInquiryTab.result
|
||||
const tabView = this.currentInquiryTab.view
|
||||
const eventName =
|
||||
this.currentInquiryTab.name && this.name
|
||||
? 'inquiry.saveAs'
|
||||
@@ -217,16 +216,6 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
// 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.currentInquiryTab.result = dataSet
|
||||
this.currentInquiryTab.view = tabView
|
||||
})
|
||||
|
||||
// Hide dialogs
|
||||
this.$modal.hide('save')
|
||||
this.$modal.hide('inquiry-conflict')
|
||||
@@ -238,7 +227,7 @@ export default {
|
||||
events.send(eventName)
|
||||
},
|
||||
_keyListener(e) {
|
||||
if (this.$route.path === '/workspace') {
|
||||
if (this.$route.path === '/workspace' && this.currentInquiryTab) {
|
||||
// Run query Ctrl+R or Ctrl+Enter
|
||||
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
300
src/components/Pivot/PivotUi/index.vue
Normal file
300
src/components/Pivot/PivotUi/index.vue
Normal 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>
|
||||
@@ -5,6 +5,7 @@
|
||||
result is not empty.
|
||||
</div>
|
||||
<pivot-ui
|
||||
v-show="showViewSettings"
|
||||
v-model="pivotOptions"
|
||||
:keyNames="columns"
|
||||
@update="$emit('update')"
|
||||
@@ -18,6 +19,7 @@
|
||||
<chart
|
||||
ref="customChart"
|
||||
v-bind="customChartComponentProps"
|
||||
:showViewSettings="showViewSettings"
|
||||
@update="$emit('update')"
|
||||
@loading-image-completed="$emit('loadingImageCompleted')"
|
||||
/>
|
||||
@@ -30,9 +32,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 +48,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 +114,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 +128,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 +228,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 +327,8 @@ export default {
|
||||
.pivot-output:empty {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
:deep(.js-plotly-plot) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -89,69 +115,27 @@
|
||||
@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,
|
||||
@@ -194,20 +177,6 @@ export default {
|
||||
showLoadingDialog: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resultSetTeleportTarget() {
|
||||
if (!this.enableTeleport) {
|
||||
return undefined
|
||||
}
|
||||
const base = `#${
|
||||
this.viewValuePanelVisible
|
||||
? 'run-result-left-pane'
|
||||
: 'run-result-result-set'
|
||||
}`
|
||||
const tabIdPostfix = `-${this.tab.id}`
|
||||
return base + tabIdPostfix
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
result() {
|
||||
this.defaultSelectedCell = null
|
||||
@@ -332,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;
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -69,7 +69,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Pager from './Pager.vue'
|
||||
import Pager from '@/components/Common/Pager.vue'
|
||||
|
||||
export default {
|
||||
name: 'SqlTable',
|
||||
@@ -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
|
||||
},
|
||||
@@ -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>
|
||||
46
src/components/svg/graph.vue
Normal file
46
src/components/svg/graph.vue
Normal 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>
|
||||
27
src/components/svg/pause.vue
Normal file
27
src/components/svg/pause.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<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.14924 3 3.33333 3H6.66667C6.85076 3 7 3.44772 7
|
||||
4V14C7 14.5523 6.85076 15 6.66667 15H3.33333C3.14924 15 3 14.5523 3 14V4Z"
|
||||
fill="#A2B1C6"
|
||||
/>
|
||||
<path
|
||||
d="M11 4C11 3.44772 11.1492 3 11.3333 3H14.6667C14.8508 3 15 3.44772 15
|
||||
4V14C15 14.5523 14.8508 15 14.6667 15H11.3333C11.1492 15 11 14.5523 11
|
||||
14V4Z"
|
||||
fill="#A2B1C6"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PauseIcon'
|
||||
}
|
||||
</script>
|
||||
36
src/components/svg/restart.vue
Normal file
36
src/components/svg/restart.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.34708 3.32922C8.63088 2.90468 10.017 2.90308 11.3022
|
||||
3.32434C11.9084 3.52308 12.4768 3.8114 12.9906 4.1759V3.34387C12.9906
|
||||
2.79159 13.4384 2.34387 13.9906 2.34387C14.5429 2.34387 14.9906 2.79159
|
||||
14.9906 3.34387V6.67102C14.9906 7.22331 14.5429 7.67102 13.9906
|
||||
7.67102H10.6635C10.1112 7.67102 9.66349 7.22331 9.66349 6.67102C9.66351
|
||||
6.11876 10.1112 5.67102 10.6635 5.67102H11.6313C11.3351 5.4851 11.0154
|
||||
5.33498 10.6791 5.22473C9.80069 4.93681 8.85289 4.93738 7.97501
|
||||
5.22766C7.09726 5.51795 6.33574 6.08228 5.80216 6.83704C5.26867
|
||||
7.59191 4.99088 8.49846 5.01017 9.42297C5.02954 10.3475 5.34482
|
||||
11.2417 5.90958 11.9738C6.47435 12.7058 7.25871 13.237 8.14787
|
||||
13.4904C9.03716 13.7437 9.9843 13.7055 10.85 13.381C11.7157 13.0565
|
||||
12.4548 12.463 12.9584 11.6876C13.2592 11.2244 13.879 11.0929 14.3422
|
||||
11.3937C14.805 11.6945 14.9366 12.3135 14.6361 12.7765C13.8996 13.9106
|
||||
12.8185 14.7794 11.5522 15.254C10.2859 15.7287 8.90058 15.7846 7.60001
|
||||
15.4142C6.2994 15.0437 5.15167 14.2661 4.3256 13.1954C3.49956 12.1247
|
||||
3.03849 10.8169 3.01017 9.46497C2.98191 8.1131 3.38782 6.7872 4.16837
|
||||
5.68274C4.94892 4.57842 6.06331 3.75379 7.34708 3.32922Z"
|
||||
fill="#A2B1C6"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RestartIcon'
|
||||
}
|
||||
</script>
|
||||
129
src/components/svg/settings.vue
Normal file
129
src/components/svg/settings.vue
Normal 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>
|
||||
52
src/lib/GraphEditorControls.jsx
Normal file
52
src/lib/GraphEditorControls.jsx
Normal 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
|
||||
@@ -13,6 +13,37 @@ export function getOptionsFromDataSources(dataSources) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function getSelectedPointsIndexes(stateData) {
|
||||
if (!stateData) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(stateData.flatMap(data => data.selectedpoints || []))
|
||||
)
|
||||
}
|
||||
|
||||
export function getRowsByIndexFromDataSources(dataSources, rowIndexes) {
|
||||
if (!dataSources) {
|
||||
return []
|
||||
}
|
||||
|
||||
const dataSourceColumns = Object.keys(dataSources)
|
||||
return rowIndexes.map(rowIndex =>
|
||||
dataSourceColumns.reduce((result, columnName) => {
|
||||
result[columnName] = dataSources[columnName][rowIndex]
|
||||
return result
|
||||
}, {})
|
||||
)
|
||||
}
|
||||
|
||||
export function clearSelection(data, layout) {
|
||||
if (layout?.selections?.length > 0) {
|
||||
layout.selections = []
|
||||
data.forEach(dataItem => delete dataItem.selectedpoints)
|
||||
}
|
||||
}
|
||||
|
||||
export function getOptionsForSave(state, dataSources) {
|
||||
// we don't need to save the data, only settings
|
||||
// so we modify state.data using dereference
|
||||
@@ -22,6 +53,9 @@ export function getOptionsForSave(state, dataSources) {
|
||||
emptySources[key] = []
|
||||
}
|
||||
dereference.default(stateCopy.data, emptySources)
|
||||
|
||||
// Also, we don't need to save selections
|
||||
clearSelection(stateCopy.data, stateCopy.layout)
|
||||
return stateCopy
|
||||
}
|
||||
|
||||
@@ -72,5 +106,8 @@ export default {
|
||||
getOptionsForSave,
|
||||
getImageDataUrl,
|
||||
getHtml,
|
||||
getChartData
|
||||
getChartData,
|
||||
getRowsByIndexFromDataSources,
|
||||
clearSelection,
|
||||
getSelectedPointsIndexes
|
||||
}
|
||||
|
||||
473
src/lib/graphHelper.js
Normal file
473
src/lib/graphHelper.js
Normal file
@@ -0,0 +1,473 @@
|
||||
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 clearNodeCoordinates(graph) {
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
attributes.x = undefined
|
||||
attributes.y = undefined
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
if (res.highlighted) {
|
||||
res.labelColor = 'black'
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,12 +1,43 @@
|
||||
import { clearSelection } from '@/lib/chartHelper'
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (installedVersion < 4) {
|
||||
inquiries.forEach(inquiry => {
|
||||
if (
|
||||
inquiry.viewType === 'graph' &&
|
||||
inquiry.viewOptions.layout.type === 'forceAtlas2'
|
||||
) {
|
||||
inquiry.viewOptions.layout.options.initialAlgorithm = 'circular'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (installedVersion < 5) {
|
||||
inquiries.forEach(inquiry => {
|
||||
if (inquiry.viewType === 'chart') {
|
||||
clearSelection(inquiry.viewOptions.data, inquiry.viewOptions.layout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return inquiries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import migration from './_migrations'
|
||||
|
||||
const migrate = migration._migrate
|
||||
const myInquiriesKey = 'myInquiries'
|
||||
const latestVersion = 5
|
||||
|
||||
export default {
|
||||
version: 2,
|
||||
version: latestVersion,
|
||||
myInquiriesKey,
|
||||
getStoredInquiries() {
|
||||
let myInquiries = JSON.parse(localStorage.getItem(myInquiriesKey))
|
||||
@@ -21,7 +22,13 @@ export default {
|
||||
return []
|
||||
}
|
||||
|
||||
return (myInquiries && myInquiries.inquiries) || []
|
||||
if (myInquiries.version < latestVersion) {
|
||||
myInquiries = migrate(myInquiries.version, myInquiries.inquiries)
|
||||
this.updateStorage(myInquiries)
|
||||
return myInquiries
|
||||
}
|
||||
|
||||
return myInquiries.inquiries || []
|
||||
},
|
||||
|
||||
duplicateInquiry(baseInquiry) {
|
||||
@@ -63,6 +70,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 < latestVersion) {
|
||||
inquiryList = migrate(inquiries.version, inquiries.inquiries)
|
||||
} else {
|
||||
inquiryList = inquiries.inquiries || []
|
||||
}
|
||||
@@ -82,11 +91,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) {
|
||||
@@ -102,6 +111,8 @@ export default {
|
||||
|
||||
if (!data.version) {
|
||||
return data.length > 0 ? migrate(1, data) : []
|
||||
} else if (data.version < latestVersion) {
|
||||
return migrate(data.version, data.inquiries)
|
||||
} else {
|
||||
return data.inquiries
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MainMenu from './MainMenu'
|
||||
import MainMenu from '@/components/MainMenu'
|
||||
import '@/assets/styles/scrollbars.css'
|
||||
|
||||
export default {
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
@@ -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.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()
|
||||
})
|
||||
@@ -92,4 +101,134 @@ describe('App.vue', () => {
|
||||
{ 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()
|
||||
})
|
||||
})
|
||||
|
||||
530
tests/components/Chart.spec.js
Normal file
530
tests/components/Chart.spec.js
Normal file
@@ -0,0 +1,530 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
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'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
describe('Chart.vue', () => {
|
||||
const $store = { state: { isWorkspaceVisible: true } }
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('getOptionsForSave called with proper arguments', () => {
|
||||
const wrapper = mount(Chart, {
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
const vm = wrapper.vm
|
||||
const stub = sinon.stub(chartHelper, 'getOptionsForSave').returns('result')
|
||||
const chartData = vm.getOptionsForSave()
|
||||
expect(stub.calledOnceWith(vm.state, vm.dataSources)).to.equal(true)
|
||||
expect(chartData).to.equal('result')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits update when plotly updates', async () => {
|
||||
const wrapper = mount(Chart, {
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
wrapper.findComponent({ ref: 'plotlyEditor' }).vm.$emit('update')
|
||||
expect(wrapper.emitted('update')).to.have.lengthOf(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('calls dereference and updates chart when dataSources is changed', async () => {
|
||||
sinon.spy(dereference, 'default')
|
||||
const dataSources = {
|
||||
name: ['Gryffindor'],
|
||||
points: [80]
|
||||
}
|
||||
|
||||
const wrapper = mount(Chart, {
|
||||
props: {
|
||||
dataSources,
|
||||
initOptions: {
|
||||
data: [
|
||||
{
|
||||
type: 'bar',
|
||||
mode: 'markers',
|
||||
x: null,
|
||||
xsrc: 'name',
|
||||
meta: {
|
||||
columnNames: {
|
||||
x: 'name',
|
||||
y: 'points',
|
||||
text: 'points'
|
||||
}
|
||||
},
|
||||
orientation: 'v',
|
||||
y: null,
|
||||
ysrc: 'points',
|
||||
text: null,
|
||||
textsrc: 'points'
|
||||
}
|
||||
],
|
||||
layout: {},
|
||||
frames: []
|
||||
}
|
||||
},
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('svg.main-svg .overplot text').text()).to.equal('80')
|
||||
const newDataSources = {
|
||||
name: ['Gryffindor'],
|
||||
points: [100]
|
||||
}
|
||||
|
||||
await wrapper.setProps({ dataSources: newDataSources })
|
||||
await flushPromises()
|
||||
expect(dereference.default.called).to.equal(true)
|
||||
expect(wrapper.find('svg.main-svg .overplot text').text()).to.equal('100')
|
||||
})
|
||||
|
||||
it('the plot resizes when the container resizes', async () => {
|
||||
const wrapper = mount(Chart, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
dataSources: null
|
||||
},
|
||||
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 container =
|
||||
wrapper.find('.chart-container').wrapperElement.parentElement
|
||||
const plot = wrapper.find('.svg-container').wrapperElement
|
||||
|
||||
const initialContainerWidth = container.scrollWidth
|
||||
const initialContainerHeight = container.scrollHeight
|
||||
|
||||
const initialPlotWidth = plot.scrollWidth
|
||||
const initialPlotHeight = plot.scrollHeight
|
||||
|
||||
const newContainerWidth = initialContainerWidth * 2 || 1000
|
||||
const newContainerHeight = initialContainerHeight * 2 || 2000
|
||||
|
||||
container.style.width = `${newContainerWidth}px`
|
||||
container.style.height = `${newContainerHeight}px`
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(plot.scrollWidth).not.to.equal(initialPlotWidth)
|
||||
expect(plot.scrollHeight).not.to.equal(initialPlotHeight)
|
||||
|
||||
container.style.width = 'unset'
|
||||
container.style.height = 'unset'
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it("doesn't calls dereference when dataSources is null", async () => {
|
||||
sinon.stub(dereference, 'default')
|
||||
const dataSources = {
|
||||
id: [1],
|
||||
name: ['foo']
|
||||
}
|
||||
|
||||
// mount the component
|
||||
const wrapper = mount(Chart, {
|
||||
props: { dataSources },
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.setProps({ dataSources: null })
|
||||
expect(dereference.default.calledOnce).to.equal(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('saveAsPng', async () => {
|
||||
sinon.spy(fIo, 'downloadFromUrl')
|
||||
const dataSources = {
|
||||
id: [1],
|
||||
name: ['foo']
|
||||
}
|
||||
|
||||
const wrapper = mount(Chart, {
|
||||
props: { dataSources },
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
sinon.spy(wrapper.vm, 'prepareCopy')
|
||||
|
||||
await nextTick() // chart is rendered
|
||||
await wrapper.vm.saveAsPng()
|
||||
|
||||
const url = await wrapper.vm.prepareCopy.returnValues[0]
|
||||
expect(wrapper.emitted().loadingImageCompleted.length).to.equal(1)
|
||||
expect(fIo.downloadFromUrl.calledOnceWith(url, 'chart'))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('dataSources are passed correctly', async () => {
|
||||
const dataSources = {
|
||||
name: ['Gryffindor'],
|
||||
points: [80]
|
||||
}
|
||||
|
||||
const wrapper = mount(Chart, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
dataSources,
|
||||
showViewSettings: true
|
||||
},
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.find('button.js-add-button').wrapperElement.click()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
await wrapper
|
||||
.find('.field .dropdown-container .Select__indicator')
|
||||
.wrapperElement.dispatchEvent(
|
||||
new MouseEvent('mousedown', { bubbles: true })
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('selections are shown in value viewer', async () => {
|
||||
const dataSources = {
|
||||
name: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
|
||||
points: [100, 90, 95, 80]
|
||||
}
|
||||
const wrapper = mount(Chart, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
dataSources,
|
||||
initOptions: {
|
||||
data: [
|
||||
{
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
x: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
|
||||
xsrc: 'name',
|
||||
meta: {
|
||||
columnNames: {
|
||||
x: 'name',
|
||||
y: 'points'
|
||||
}
|
||||
},
|
||||
y: [100, 90, 95, 80],
|
||||
ysrc: 'points',
|
||||
selectedpoints: [1, 2]
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
autosize: true,
|
||||
mapbox: {
|
||||
style: 'open-street-map'
|
||||
},
|
||||
dragmode: 'select',
|
||||
selections: [
|
||||
{
|
||||
xref: 'x',
|
||||
yref: 'y',
|
||||
line: {
|
||||
width: 1,
|
||||
dash: 'dot'
|
||||
},
|
||||
type: 'rect',
|
||||
x0: 0.6238202370500439,
|
||||
y0: 98.1184336198663,
|
||||
x1: 2.4324242756804213,
|
||||
y1: 87.03915950334289
|
||||
}
|
||||
],
|
||||
title: {
|
||||
subtitle: {
|
||||
text: 'Click to enter Plot subtitle'
|
||||
}
|
||||
},
|
||||
xaxis: {
|
||||
range: [-0.20324846356453027, 3.20324846356453],
|
||||
autorange: true,
|
||||
type: 'category'
|
||||
},
|
||||
yaxis: {
|
||||
range: [78.4909264565425, 101.5090735434575],
|
||||
autorange: true,
|
||||
type: 'linear'
|
||||
}
|
||||
},
|
||||
frames: []
|
||||
},
|
||||
showViewSettings: true,
|
||||
showValueViewer: true
|
||||
},
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
const valueViewerText = wrapper
|
||||
.findComponent({ name: 'ValueViewer' })
|
||||
.text()
|
||||
|
||||
expect(valueViewerText).to.contain(`"name": "Hufflepuff"`)
|
||||
expect(valueViewerText).to.contain(`"points": 90`)
|
||||
|
||||
expect(valueViewerText).to.contain(`"name": "Ravenclaw"`)
|
||||
expect(valueViewerText).to.contain(`"points": 95`)
|
||||
|
||||
expect(valueViewerText).not.to.contain(`"name": "Gryffindor"`)
|
||||
expect(valueViewerText).not.to.contain(`"points": 100`)
|
||||
|
||||
expect(valueViewerText).not.to.contain(`"name": "Slytherin"`)
|
||||
expect(valueViewerText).not.to.contain(`"points": 80`)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears selections on dataSources change', async () => {
|
||||
const dataSources = {
|
||||
name: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
|
||||
points: [100, 90, 95, 80]
|
||||
}
|
||||
const wrapper = mount(Chart, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
dataSources,
|
||||
initOptions: {
|
||||
data: [
|
||||
{
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
x: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
|
||||
xsrc: 'name',
|
||||
meta: {
|
||||
columnNames: {
|
||||
x: 'name',
|
||||
y: 'points'
|
||||
}
|
||||
},
|
||||
y: [100, 90, 95, 80],
|
||||
ysrc: 'points',
|
||||
selectedpoints: [1, 2]
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
autosize: true,
|
||||
mapbox: {
|
||||
style: 'open-street-map'
|
||||
},
|
||||
dragmode: 'select',
|
||||
selections: [
|
||||
{
|
||||
xref: 'x',
|
||||
yref: 'y',
|
||||
line: {
|
||||
width: 1,
|
||||
dash: 'dot'
|
||||
},
|
||||
type: 'rect',
|
||||
x0: 0.6238202370500439,
|
||||
y0: 98.1184336198663,
|
||||
x1: 2.4324242756804213,
|
||||
y1: 87.03915950334289
|
||||
}
|
||||
],
|
||||
title: {
|
||||
subtitle: {
|
||||
text: 'Click to enter Plot subtitle'
|
||||
}
|
||||
},
|
||||
xaxis: {
|
||||
range: [-0.20324846356453027, 3.20324846356453],
|
||||
autorange: true,
|
||||
type: 'category'
|
||||
},
|
||||
yaxis: {
|
||||
range: [78.4909264565425, 101.5090735434575],
|
||||
autorange: true,
|
||||
type: 'linear'
|
||||
}
|
||||
},
|
||||
frames: []
|
||||
},
|
||||
showViewSettings: true,
|
||||
showValueViewer: true
|
||||
},
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
const valueViewerText = wrapper
|
||||
.findComponent({ name: 'ValueViewer' })
|
||||
.text()
|
||||
|
||||
expect(valueViewerText).to.contain(`"name": "Hufflepuff"`)
|
||||
expect(valueViewerText).to.contain(`"name": "Ravenclaw"`)
|
||||
|
||||
await wrapper.setProps({
|
||||
dataSources: {
|
||||
name: ['Gryffindor', 'Hufflepuff'],
|
||||
points: [100, 90]
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'ValueViewer' }).text()).equal(
|
||||
`No points selected to view`
|
||||
)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears selections on changes in chart settings', async () => {
|
||||
const dataSources = {
|
||||
name: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
|
||||
points: [100, 90, 95, 80]
|
||||
}
|
||||
const wrapper = mount(Chart, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
dataSources,
|
||||
initOptions: {
|
||||
data: [
|
||||
{
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
x: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
|
||||
xsrc: 'name',
|
||||
meta: {
|
||||
columnNames: {
|
||||
x: 'name',
|
||||
y: 'points'
|
||||
}
|
||||
},
|
||||
y: [100, 90, 95, 80],
|
||||
ysrc: 'points',
|
||||
selectedpoints: [1, 2]
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
autosize: true,
|
||||
mapbox: {
|
||||
style: 'open-street-map'
|
||||
},
|
||||
dragmode: 'select',
|
||||
selections: [
|
||||
{
|
||||
xref: 'x',
|
||||
yref: 'y',
|
||||
line: {
|
||||
width: 1,
|
||||
dash: 'dot'
|
||||
},
|
||||
type: 'rect',
|
||||
x0: 0.6238202370500439,
|
||||
y0: 98.1184336198663,
|
||||
x1: 2.4324242756804213,
|
||||
y1: 87.03915950334289
|
||||
}
|
||||
],
|
||||
title: {
|
||||
subtitle: {
|
||||
text: 'Click to enter Plot subtitle'
|
||||
}
|
||||
},
|
||||
xaxis: {
|
||||
range: [-0.20324846356453027, 3.20324846356453],
|
||||
autorange: true,
|
||||
type: 'category'
|
||||
},
|
||||
yaxis: {
|
||||
range: [78.4909264565425, 101.5090735434575],
|
||||
autorange: true,
|
||||
type: 'linear'
|
||||
}
|
||||
},
|
||||
frames: []
|
||||
},
|
||||
showViewSettings: true,
|
||||
showValueViewer: true
|
||||
},
|
||||
global: {
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
const valueViewerText = wrapper
|
||||
.findComponent({ name: 'ValueViewer' })
|
||||
.text()
|
||||
|
||||
expect(valueViewerText).to.contain(`"name": "Hufflepuff"`)
|
||||
expect(valueViewerText).to.contain(`"name": "Ravenclaw"`)
|
||||
|
||||
// Add another trace
|
||||
await wrapper.find('button.js-add-button').wrapperElement.click()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'ValueViewer' }).text()).equal(
|
||||
`No points selected to view`
|
||||
)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
@@ -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 () => {
|
||||
@@ -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
|
||||
@@ -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(() => {
|
||||
@@ -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, {
|
||||
@@ -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(() => {
|
||||
@@ -1,6 +1,6 @@
|
||||
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'
|
||||
@@ -15,7 +15,7 @@ describe('DataView.vue', () => {
|
||||
it('emits update on mode changing', async () => {
|
||||
const wrapper = mount(DataView, {
|
||||
global: {
|
||||
stubs: { chart: true }
|
||||
mocks: { $store }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,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' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,13 +56,28 @@ 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' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -87,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' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -114,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()
|
||||
})
|
||||
|
||||
@@ -228,7 +322,6 @@ describe('DataView.vue', () => {
|
||||
// Wait untill prepareCopy is finished
|
||||
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
|
||||
|
||||
await nextTick()
|
||||
// The dialog is not shown...
|
||||
await clock.tick(100)
|
||||
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||
@@ -277,4 +370,107 @@ describe('DataView.vue', () => {
|
||||
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' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// viewDetailsBtn is disaplyed in chart mode
|
||||
expect(wrapper.findComponent({ ref: 'viewDetailsBtn' }).exists()).to.equal(
|
||||
true
|
||||
)
|
||||
|
||||
// Switch to pivot
|
||||
const pivotBtn = wrapper.findComponent({ ref: 'pivotBtn' })
|
||||
await pivotBtn.trigger('click')
|
||||
|
||||
// viewDetailsBtn is not disaplyed in pivot mode
|
||||
expect(wrapper.findComponent({ ref: 'viewDetailsBtn' }).exists()).to.equal(
|
||||
false
|
||||
)
|
||||
|
||||
// Switch to graph
|
||||
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
|
||||
await graphBtn.trigger('click')
|
||||
|
||||
// viewDetailsBtn is disaplyed in graph mode
|
||||
const viewDetailsBtn = wrapper.findComponent({
|
||||
ref: 'viewDetailsBtn'
|
||||
})
|
||||
expect(viewDetailsBtn.exists()).to.equal(true)
|
||||
|
||||
// by default details viewer is hidden
|
||||
expect(wrapper.findComponent({ name: 'value-viewer' }).exists()).to.equal(
|
||||
false
|
||||
)
|
||||
|
||||
// Click to show details viewer
|
||||
await viewDetailsBtn.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'value-viewer' }).exists()).to.equal(
|
||||
true
|
||||
)
|
||||
|
||||
// Switch to chart
|
||||
const chartBtn = wrapper.findComponent({ ref: 'chartBtn' })
|
||||
await chartBtn.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'value-viewer' }).exists()).to.equal(
|
||||
true
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
825
tests/components/Graph/Graph.spec.js
Normal file
825
tests/components/Graph/Graph.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
2416
tests/components/Graph/GraphEditor.spec.js
Normal file
2416
tests/components/Graph/GraphEditor.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ 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'
|
||||
@@ -210,8 +210,8 @@ describe('MainMenu.vue', () => {
|
||||
isSaved: false
|
||||
}
|
||||
const state = {
|
||||
currentTab: tab,
|
||||
tabs: [tab],
|
||||
currentTab: null,
|
||||
tabs: [],
|
||||
db: {}
|
||||
}
|
||||
const store = createStore({ state })
|
||||
@@ -228,6 +228,17 @@ describe('MainMenu.vue', () => {
|
||||
|
||||
const ctrlR = new KeyboardEvent('keydown', { key: 'r', ctrlKey: true })
|
||||
const metaR = new KeyboardEvent('keydown', { key: 'r', metaKey: true })
|
||||
|
||||
// execute is not called - no tabs open
|
||||
document.dispatchEvent(ctrlR)
|
||||
expect(tab.execute.calledOnce).to.equal(false)
|
||||
document.dispatchEvent(metaR)
|
||||
expect(tab.execute.calledOnce).to.equal(false)
|
||||
|
||||
// Open a tab
|
||||
store.state.currentTab = tab
|
||||
store.state.tabs = [tab]
|
||||
|
||||
// Running is enabled and route path is workspace
|
||||
document.dispatchEvent(ctrlR)
|
||||
expect(state.currentTab.execute.calledOnce).to.equal(true)
|
||||
@@ -257,8 +268,8 @@ describe('MainMenu.vue', () => {
|
||||
isSaved: false
|
||||
}
|
||||
const state = {
|
||||
currentTab: tab,
|
||||
tabs: [tab],
|
||||
currentTab: null,
|
||||
tabs: [],
|
||||
db: {}
|
||||
}
|
||||
const store = createStore({ state })
|
||||
@@ -281,6 +292,17 @@ describe('MainMenu.vue', () => {
|
||||
key: 'Enter',
|
||||
metaKey: true
|
||||
})
|
||||
|
||||
// execute is not called - no tabs open
|
||||
document.dispatchEvent(ctrlEnter)
|
||||
expect(tab.execute.calledOnce).to.equal(false)
|
||||
document.dispatchEvent(metaEnter)
|
||||
expect(tab.execute.calledOnce).to.equal(false)
|
||||
|
||||
// Open a tab
|
||||
store.state.currentTab = tab
|
||||
store.state.tabs = [tab]
|
||||
|
||||
// Running is enabled and route path is workspace
|
||||
document.dispatchEvent(ctrlEnter)
|
||||
expect(state.currentTab.execute.calledOnce).to.equal(true)
|
||||
@@ -347,8 +369,8 @@ describe('MainMenu.vue', () => {
|
||||
isSaved: false
|
||||
}
|
||||
const state = {
|
||||
currentTab: tab,
|
||||
tabs: [tab],
|
||||
currentTab: null,
|
||||
tabs: [],
|
||||
db: {}
|
||||
}
|
||||
const store = createStore({ state })
|
||||
@@ -365,6 +387,17 @@ describe('MainMenu.vue', () => {
|
||||
|
||||
const ctrlS = new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||
const metaS = new KeyboardEvent('keydown', { key: 's', metaKey: true })
|
||||
|
||||
// onSave is not called - no tabs open
|
||||
document.dispatchEvent(ctrlS)
|
||||
expect(wrapper.vm.onSave.calledOnce).to.equal(false)
|
||||
document.dispatchEvent(metaS)
|
||||
expect(wrapper.vm.onSave.calledOnce).to.equal(false)
|
||||
|
||||
// Open a tab
|
||||
store.state.currentTab = tab
|
||||
store.state.tabs = [tab]
|
||||
|
||||
// tab is unsaved and route is /workspace
|
||||
document.dispatchEvent(ctrlS)
|
||||
expect(wrapper.vm.onSave.calledOnce).to.equal(true)
|
||||
@@ -394,8 +427,8 @@ describe('MainMenu.vue', () => {
|
||||
isSaved: false
|
||||
}
|
||||
const state = {
|
||||
currentTab: tab,
|
||||
tabs: [tab],
|
||||
currentTab: null,
|
||||
tabs: [],
|
||||
db: {}
|
||||
}
|
||||
const store = createStore({ state })
|
||||
@@ -420,6 +453,17 @@ describe('MainMenu.vue', () => {
|
||||
metaKey: true,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
// onSaveAs is not called - no tabs open
|
||||
document.dispatchEvent(ctrlS)
|
||||
expect(wrapper.vm.onSaveAs.calledOnce).to.equal(false)
|
||||
document.dispatchEvent(metaS)
|
||||
expect(wrapper.vm.onSaveAs.calledOnce).to.equal(false)
|
||||
|
||||
// Open a tab
|
||||
store.state.currentTab = tab
|
||||
store.state.tabs = [tab]
|
||||
|
||||
// tab is unsaved and route is /workspace
|
||||
document.dispatchEvent(ctrlS)
|
||||
expect(wrapper.vm.onSaveAs.calledOnce).to.equal(true)
|
||||
@@ -608,8 +652,6 @@ describe('MainMenu.vue', () => {
|
||||
.find(button => button.text() === 'Overwrite')
|
||||
.trigger('click')
|
||||
|
||||
await nextTick()
|
||||
|
||||
// check that the dialog is closed
|
||||
await clock.tick(100)
|
||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||
@@ -694,8 +736,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)
|
||||
@@ -725,8 +765,6 @@ describe('MainMenu.vue', () => {
|
||||
.find(button => button.text() === 'Overwrite')
|
||||
.trigger('click')
|
||||
|
||||
await nextTick()
|
||||
|
||||
// check that the dialog is closed
|
||||
await clock.tick(100)
|
||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||
@@ -759,8 +797,7 @@ describe('MainMenu.vue', () => {
|
||||
createdAt: '2025-05-14T15:30:00Z'
|
||||
}
|
||||
],
|
||||
tabs: [tab],
|
||||
db: {}
|
||||
tabs: [tab]
|
||||
}
|
||||
const mutations = {
|
||||
updateTab: sinon.stub()
|
||||
@@ -795,22 +832,29 @@ describe('MainMenu.vue', () => {
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
await nextTick()
|
||||
// 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')
|
||||
|
||||
@@ -820,8 +864,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)
|
||||
@@ -1062,8 +1104,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)
|
||||
@@ -1172,8 +1212,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)
|
||||
@@ -1206,19 +1244,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 () => {
|
||||
@@ -1364,8 +1389,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)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
@@ -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 () => {
|
||||
@@ -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', () => {
|
||||
@@ -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 () => {
|
||||
@@ -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: {
|
||||
@@ -114,7 +112,6 @@ describe('RunResult.vue', () => {
|
||||
const wrapper = mount(RunResult, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
tab: { id: 1 },
|
||||
result: {
|
||||
columns: ['id', 'name'],
|
||||
values: {
|
||||
@@ -139,7 +136,6 @@ describe('RunResult.vue', () => {
|
||||
|
||||
// Switch to microtasks (let serialize run)
|
||||
await clock.tick(0)
|
||||
await nextTick()
|
||||
|
||||
// The dialog is not shown...
|
||||
await clock.tick(100)
|
||||
@@ -155,7 +151,6 @@ describe('RunResult.vue', () => {
|
||||
const wrapper = mount(RunResult, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
tab: { id: 1 },
|
||||
result: {
|
||||
columns: ['id', 'name'],
|
||||
values: {
|
||||
@@ -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: {
|
||||
@@ -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'
|
||||
@@ -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', () => {
|
||||
@@ -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', () => {
|
||||
@@ -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', () => {
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@ 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'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -21,37 +21,69 @@ describe('chartHelper.js', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('getSelectedPointsIndexes returns unique selected point indexes', () => {
|
||||
const stateData = [
|
||||
{ selectedpoints: [1, 2, 3] },
|
||||
{ selectedpoints: [3, 4] },
|
||||
{ selectedpoints: [] },
|
||||
{}
|
||||
]
|
||||
|
||||
const indexes = chartHelper.getSelectedPointsIndexes(stateData)
|
||||
|
||||
expect(indexes).to.eql([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('getOptionsForSave', () => {
|
||||
const state = {
|
||||
data: {
|
||||
foo: {},
|
||||
bar: {}
|
||||
},
|
||||
layout: {},
|
||||
data: [
|
||||
{
|
||||
foo: {},
|
||||
bar: {},
|
||||
selectedpoints: []
|
||||
}
|
||||
],
|
||||
layout: { selections: [{}] },
|
||||
frames: {}
|
||||
}
|
||||
const dataSources = {
|
||||
id: [1, 2],
|
||||
name: ['foo', 'bar']
|
||||
}
|
||||
sinon.stub(dereference, 'default')
|
||||
const dereferenceArgs = []
|
||||
sinon.stub(dereference, 'default').callsFake((data, emptySources) => {
|
||||
dereferenceArgs[0] = JSON.stringify(data)
|
||||
dereferenceArgs[1] = JSON.stringify(emptySources)
|
||||
})
|
||||
sinon.spy(JSON, 'parse')
|
||||
|
||||
const ds = chartHelper.getOptionsForSave(state, dataSources)
|
||||
|
||||
expect(dereference.default.calledOnce).to.equal(true)
|
||||
|
||||
const args = dereference.default.firstCall.args
|
||||
expect(args[0]).to.eql({
|
||||
foo: {},
|
||||
bar: {}
|
||||
})
|
||||
expect(args[1]).to.eql({
|
||||
id: [],
|
||||
name: []
|
||||
})
|
||||
expect(dereferenceArgs[0]).to.eql(
|
||||
JSON.stringify([
|
||||
{
|
||||
foo: {},
|
||||
bar: {},
|
||||
selectedpoints: []
|
||||
}
|
||||
])
|
||||
)
|
||||
expect(dereferenceArgs[1]).to.eql(
|
||||
JSON.stringify({
|
||||
id: [],
|
||||
name: []
|
||||
})
|
||||
)
|
||||
|
||||
expect(ds).to.equal(JSON.parse.returnValues[0])
|
||||
expect(ds.layout.selections).to.eql([])
|
||||
expect(ds.data).to.eql([
|
||||
{
|
||||
foo: {},
|
||||
bar: {}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('getImageDataUrl returns dataUrl', async () => {
|
||||
@@ -101,4 +133,32 @@ describe('chartHelper.js', () => {
|
||||
'Plotly.newPlot(el, "plotly data", "plotly layout"'
|
||||
)
|
||||
})
|
||||
|
||||
it('getRowsByIndexFromDataSources', () => {
|
||||
const dataSources = {
|
||||
id: [1, 2, 3],
|
||||
name: ['Harry', 'Draco', 'Ron'],
|
||||
points: [10, null, 7]
|
||||
}
|
||||
const rows = chartHelper.getRowsByIndexFromDataSources(dataSources, [1, 2])
|
||||
expect(rows).to.eql([
|
||||
{ id: 2, name: 'Draco', points: null },
|
||||
{ id: 3, name: 'Ron', points: 7 }
|
||||
])
|
||||
})
|
||||
|
||||
it('clearSelection', () => {
|
||||
const layout = {
|
||||
selections: [{}],
|
||||
someLayoutField: 1
|
||||
}
|
||||
const data = [
|
||||
{ selectedpoints: [], someField: 1 },
|
||||
{ someField: 2 },
|
||||
{ selectedpoints: [], someField: 3 }
|
||||
]
|
||||
chartHelper.clearSelection(data, layout)
|
||||
expect(layout).to.eql({ selections: [], someLayoutField: 1 })
|
||||
expect(data).to.eql([{ someField: 1 }, { someField: 2 }, { someField: 3 }])
|
||||
})
|
||||
})
|
||||
|
||||
2266
tests/lib/graphHelper.spec.js
Normal file
2266
tests/lib/graphHelper.spec.js
Normal file
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
Reference in New Issue
Block a user