mirror of
https://github.com/lana-k/sqliteviz.git
synced 2025-12-06 18:18:53 +08:00
Compare commits
20 Commits
4232f15c04
...
218ab52ab3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218ab52ab3 | ||
|
|
f178937440 | ||
|
|
411bd694c0 | ||
|
|
d2969de127 | ||
|
|
b59c21c14e | ||
|
|
4ed4b54a28 | ||
|
|
2c2bb7d6d3 | ||
|
|
efbd985b36 | ||
|
|
9cf7d0e5dc | ||
|
|
0a8c09b58d | ||
|
|
931cf380bc | ||
|
|
f0f96ac663 | ||
|
|
45530cc9d6 | ||
|
|
6fbf75b601 | ||
|
|
d3fbf08569 | ||
|
|
be6a19a30f | ||
|
|
07d7a9d54b | ||
|
|
cdd925b8af | ||
|
|
12fa0749b1 | ||
|
|
75bf849823 |
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
@@ -21,8 +21,9 @@ jobs:
|
|||||||
- name: Install browsers
|
- name: Install browsers
|
||||||
run: |
|
run: |
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
sudo add-apt-repository -y ppa:mozillateam/ppa
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y chromium-browser firefox
|
sudo apt-get install -y chromium-browser firefox-esr
|
||||||
|
|
||||||
- name: Update npm
|
- name: Update npm
|
||||||
run: npm install -g npm@10
|
run: npm install -g npm@10
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# docker build -t sqliteviz/test -f Dockerfile.test .
|
# docker build -t sqliteviz/test -f Dockerfile.test .
|
||||||
#
|
#
|
||||||
|
|
||||||
FROM node:12.22-buster
|
FROM node:12.22-bullseye
|
||||||
|
|
||||||
RUN set -ex; \
|
RUN set -ex; \
|
||||||
apt update; \
|
apt update; \
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@*": ["./src/*"]
|
"@\/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
from urllib import request
|
from urllib import request
|
||||||
|
|
||||||
|
|
||||||
amalgamation_url = 'https://sqlite.org/2023/sqlite-amalgamation-3410000.zip'
|
amalgamation_url = 'https://sqlite.org/2025/sqlite-amalgamation-3500300.zip'
|
||||||
|
|
||||||
# Extension-functions
|
# Extension-functions
|
||||||
# ===================
|
# ===================
|
||||||
@@ -22,15 +22,15 @@ contrib_functions_url = 'https://sqlite.org/contrib/download/extension-functions
|
|||||||
extension_urls = (
|
extension_urls = (
|
||||||
# Miscellaneous extensions
|
# Miscellaneous extensions
|
||||||
# ========================
|
# ========================
|
||||||
('https://sqlite.org/src/raw/8d79354f?at=series.c', 'sqlite3_series_init'),
|
('https://sqlite.org/src/raw/e212edb2?at=series.c', 'sqlite3_series_init'),
|
||||||
('https://sqlite.org/src/raw/dbfd8543?at=closure.c', 'sqlite3_closure_init'),
|
('https://sqlite.org/src/raw/5559daf1?at=closure.c', 'sqlite3_closure_init'),
|
||||||
('https://sqlite.org/src/raw/5bb2264c?at=uuid.c', 'sqlite3_uuid_init'),
|
('https://sqlite.org/src/raw/5bb2264c?at=uuid.c', 'sqlite3_uuid_init'),
|
||||||
('https://sqlite.org/src/raw/5853b0e5?at=regexp.c', 'sqlite3_regexp_init'),
|
('https://sqlite.org/src/raw/388e7f23?at=regexp.c', 'sqlite3_regexp_init'),
|
||||||
('https://sqlite.org/src/raw/b9086e22?at=percentile.c', 'sqlite3_percentile_init'),
|
('https://sqlite.org/src/raw/72e05a21?at=percentile.c', 'sqlite3_percentile_init'),
|
||||||
('https://sqlite.org/src/raw/09f967dc?at=decimal.c', 'sqlite3_decimal_init'),
|
('https://sqlite.org/src/raw/228d47e9?at=decimal.c', 'sqlite3_decimal_init'),
|
||||||
# Third-party extension
|
# Third-party extension
|
||||||
# =====================
|
# =====================
|
||||||
('https://github.com/jakethaw/pivot_vtab/raw/9323ef93/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
|
('https://github.com/jakethaw/pivot_vtab/raw/e7705f34/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
|
||||||
('https://github.com/nalgeon/sqlean/raw/95e8d21a/src/pearson.c', 'sqlite3_pearson_init'),
|
('https://github.com/nalgeon/sqlean/raw/95e8d21a/src/pearson.c', 'sqlite3_pearson_init'),
|
||||||
# Third-party extension with own dependencies
|
# Third-party extension with own dependencies
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
2
lib/sql-js/dist/sql-wasm.js
vendored
2
lib/sql-js/dist/sql-wasm.js
vendored
File diff suppressed because one or more lines are too long
BIN
lib/sql-js/dist/sql-wasm.wasm
vendored
BIN
lib/sql-js/dist/sql-wasm.wasm
vendored
Binary file not shown.
17245
package-lock.json
generated
17245
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sqliteviz",
|
"name": "sqliteviz",
|
||||||
"version": "0.26.0",
|
"version": "0.27.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,11 +13,15 @@
|
|||||||
"format": "prettier . --write"
|
"format": "prettier . --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sigma/export-image": "^3.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"codemirror": "^5.65.18",
|
"codemirror": "^5.65.18",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"dataurl-to-blob": "^0.0.1",
|
"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",
|
"html2canvas": "^1.1.4",
|
||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"nanoid": "^3.1.12",
|
"nanoid": "^3.1.12",
|
||||||
@@ -28,6 +32,8 @@
|
|||||||
"react": "^16.14.0",
|
"react": "^16.14.0",
|
||||||
"react-chart-editor": "^0.46.1",
|
"react-chart-editor": "^0.46.1",
|
||||||
"react-dom": "^16.14.0",
|
"react-dom": "^16.14.0",
|
||||||
|
"seedrandom": "^3.0.5",
|
||||||
|
"sigma": "^3.0.1",
|
||||||
"sql.js": "file:./lib/sql-js",
|
"sql.js": "file:./lib/sql-js",
|
||||||
"tiny-emitter": "^2.1.0",
|
"tiny-emitter": "^2.1.0",
|
||||||
"veaury": "^2.5.1",
|
"veaury": "^2.5.1",
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<router-view />
|
<router-view />
|
||||||
<modals-container />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import storedInquiries from '@/lib/storedInquiries'
|
import storedInquiries from '@/lib/storedInquiries'
|
||||||
import { ModalsContainer } from 'vue-final-modal'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { ModalsContainer },
|
|
||||||
computed: {
|
computed: {
|
||||||
inquiries() {
|
inquiries() {
|
||||||
return this.$store.state.inquiries
|
return this.$store.state.inquiries
|
||||||
@@ -26,6 +23,11 @@ export default {
|
|||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
|
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
|
||||||
|
addEventListener('storage', event => {
|
||||||
|
if (event.key === storedInquiries.myInquiriesKey) {
|
||||||
|
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
117
src/components/Graph/AdvancedForceAtlasLayoutSettings.vue
Normal file
117
src/components/Graph/AdvancedForceAtlasLayoutSettings.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Adjust sizes">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="booleanOptions"
|
||||||
|
:activeOption="modelValue.adjustSizes"
|
||||||
|
@option-change="update('adjustSizes', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Barnes-Hut optimize">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="booleanOptions"
|
||||||
|
:activeOption="modelValue.barnesHutOptimize"
|
||||||
|
@option-change="update('barnesHutOptimize', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field v-show="modelValue.barnesHutOptimize" label="Barnes-Hut Theta">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.barnesHutTheta"
|
||||||
|
@update="update('barnesHutTheta', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Gravity">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.gravity"
|
||||||
|
@update="update('gravity', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Strong gravity mode">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="booleanOptions"
|
||||||
|
:activeOption="modelValue.strongGravityMode"
|
||||||
|
@option-change="update('strongGravityMode', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Noack's LinLog model">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="booleanOptions"
|
||||||
|
:activeOption="modelValue.linLogMode"
|
||||||
|
@option-change="update('linLogMode', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Out bound attraction distribution">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="booleanOptions"
|
||||||
|
:activeOption="modelValue.outboundAttractionDistribution"
|
||||||
|
@option-change="update('outboundAttractionDistribution', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Slow down">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.slowDown"
|
||||||
|
:min="0"
|
||||||
|
@update="update('slowDown', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Edge weight">
|
||||||
|
<Dropdown
|
||||||
|
:options="keyOptions"
|
||||||
|
:value="modelValue.weightSource"
|
||||||
|
@change="update('weightSource', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field v-show="modelValue.weightSource" label="Edge 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>
|
||||||
77
src/components/Graph/CirclePackLayoutSettings.vue
Normal file
77
src/components/Graph/CirclePackLayoutSettings.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<Field
|
||||||
|
label="Hierarchy attributes"
|
||||||
|
fieldContainerClassName="multiselect-field"
|
||||||
|
>
|
||||||
|
<multiselect
|
||||||
|
:modelValue="modelValue.hierarchyAttributes"
|
||||||
|
class="sqliteviz-select"
|
||||||
|
:options="keyOptions"
|
||||||
|
:multiple="true"
|
||||||
|
:hideSelected="true"
|
||||||
|
:closeOnSelect="true"
|
||||||
|
:showLabels="false"
|
||||||
|
:max="keyOptions.length"
|
||||||
|
placeholder=""
|
||||||
|
openDirection="bottom"
|
||||||
|
@update:model-value="update('hierarchyAttributes', $event)"
|
||||||
|
>
|
||||||
|
<template #maxElements>
|
||||||
|
<span class="no-results">No Results</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #placeholder>Select an Option</template>
|
||||||
|
|
||||||
|
<template #noResult>
|
||||||
|
<span class="no-results">No Results</span>
|
||||||
|
</template>
|
||||||
|
</multiselect>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Seed value">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.seedValue"
|
||||||
|
@update="update('seedValue', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { applyPureReactInVue } from 'veaury'
|
||||||
|
import Field from 'react-chart-editor/lib/components/fields/Field'
|
||||||
|
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
|
||||||
|
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
|
||||||
|
import Multiselect from 'vue-multiselect'
|
||||||
|
import 'react-chart-editor/lib/react-chart-editor.css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Field: applyPureReactInVue(Field),
|
||||||
|
NumericInput: applyPureReactInVue(NumericInput),
|
||||||
|
Dropdown: applyPureReactInVue(Dropdown),
|
||||||
|
Multiselect
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
modelValue: Object,
|
||||||
|
keyOptions: Array
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
methods: {
|
||||||
|
update(attributeName, value) {
|
||||||
|
this.$emit('update:modelValue', {
|
||||||
|
...this.modelValue,
|
||||||
|
[attributeName]: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.sqliteviz-select.multiselect--active .multiselect__input) {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
:deep(.multiselect-field .field__widget > *) {
|
||||||
|
flex-grow: 1 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
137
src/components/Graph/EdgeColorSettings.vue
Normal file
137
src/components/Graph/EdgeColorSettings.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Color">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="edgeColorTypeOptions"
|
||||||
|
:activeOption="modelValue.type"
|
||||||
|
@option-change="updateColorType"
|
||||||
|
/>
|
||||||
|
<Field v-if="modelValue.type === 'constant'">
|
||||||
|
<ColorPicker
|
||||||
|
:selectedColor="modelValue.value"
|
||||||
|
@color-change="updateSettings('value', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<template v-else>
|
||||||
|
<Field>
|
||||||
|
<Dropdown
|
||||||
|
v-if="modelValue.type === 'variable'"
|
||||||
|
:options="keyOptions"
|
||||||
|
:value="modelValue.source"
|
||||||
|
@change="updateSettings('source', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<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'" label="Color as">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="сolorAsOptions"
|
||||||
|
:activeOption="modelValue.mode"
|
||||||
|
@option-change="updateSettings('mode', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field v-if="modelValue.type !== 'constant'" label="Colorscale direction">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="сolorscaleDirections"
|
||||||
|
:activeOption="modelValue.colorscaleDirection"
|
||||||
|
@option-change="updateSettings('colorscaleDirection', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { markRaw } from 'vue'
|
||||||
|
import { applyPureReactInVue } from 'veaury'
|
||||||
|
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
|
||||||
|
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
|
||||||
|
import ColorscalePicker from 'react-chart-editor/lib/components/widgets/ColorscalePicker'
|
||||||
|
import ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
|
||||||
|
import Field from 'react-chart-editor/lib/components/fields/Field'
|
||||||
|
import 'react-chart-editor/lib/react-chart-editor.css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Dropdown: applyPureReactInVue(Dropdown),
|
||||||
|
RadioBlocks: applyPureReactInVue(RadioBlocks),
|
||||||
|
Field: applyPureReactInVue(Field),
|
||||||
|
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
|
||||||
|
ColorPicker: applyPureReactInVue(ColorPicker)
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
modelValue: Object,
|
||||||
|
keyOptions: Array
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
edgeColorTypeOptions: markRaw([
|
||||||
|
{ label: 'Constant', value: 'constant' },
|
||||||
|
{ label: 'Variable', value: 'variable' }
|
||||||
|
]),
|
||||||
|
сolorAsOptions: markRaw([
|
||||||
|
{ label: 'Continious', value: 'continious' },
|
||||||
|
{ label: 'Categorical', value: 'categorical' }
|
||||||
|
]),
|
||||||
|
сolorscaleDirections: markRaw([
|
||||||
|
{ label: 'Normal', value: 'normal' },
|
||||||
|
{ label: 'Recersed', value: 'reversed' }
|
||||||
|
]),
|
||||||
|
colorSourceUsageOptions: markRaw([
|
||||||
|
{ label: 'Direct', value: 'direct' },
|
||||||
|
{ label: 'Map to', value: 'map_to' }
|
||||||
|
]),
|
||||||
|
defaultColorSettings: {
|
||||||
|
constant: { value: '#1F77B4' },
|
||||||
|
variable: {
|
||||||
|
source: null,
|
||||||
|
sourceUsage: 'map_to',
|
||||||
|
colorscale: null,
|
||||||
|
mode: 'categorical',
|
||||||
|
colorscaleDirection: 'normal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
updateColorType(newColorType) {
|
||||||
|
const currentColorType = this.modelValue.type
|
||||||
|
this.defaultColorSettings[currentColorType] = this.modelValue
|
||||||
|
|
||||||
|
this.$emit('update:modelValue', {
|
||||||
|
type: newColorType,
|
||||||
|
...this.defaultColorSettings[newColorType]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateSettings(attributeName, value) {
|
||||||
|
this.$emit('update:modelValue', {
|
||||||
|
...this.modelValue,
|
||||||
|
[attributeName]: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.customPickerContainer) {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
src/components/Graph/EdgeSizeSettings.vue
Normal file
93
src/components/Graph/EdgeSizeSettings.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Size">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="edgeSizeTypeOptions"
|
||||||
|
:activeOption="modelValue.type"
|
||||||
|
@option-change="updateSizeType"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<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">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.scale"
|
||||||
|
@update="updateSettings('scale', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Minimum size">
|
||||||
|
<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>
|
||||||
43
src/components/Graph/ForceAtlasLayoutSettings.vue
Normal file
43
src/components/Graph/ForceAtlasLayoutSettings.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Initial iterations">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.initialIterationsAmount"
|
||||||
|
:min="1"
|
||||||
|
@update="update('initialIterationsAmount', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Scaling ratio">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.scalingRatio"
|
||||||
|
@update="update('scalingRatio', $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>
|
||||||
155
src/components/Graph/NodeColorSettings.vue
Normal file
155
src/components/Graph/NodeColorSettings.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Color">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="nodeColorTypeOptions"
|
||||||
|
:activeOption="modelValue.type"
|
||||||
|
@option-change="updateColorType"
|
||||||
|
/>
|
||||||
|
<Field v-if="modelValue.type === 'constant'">
|
||||||
|
<ColorPicker
|
||||||
|
:selectedColor="modelValue.value"
|
||||||
|
@color-change="updateSettings('value', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<template v-else>
|
||||||
|
<Field>
|
||||||
|
<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"
|
||||||
|
@change="updateSettings('method', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<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'" label="Color as">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="сolorAsOptions"
|
||||||
|
:activeOption="modelValue.mode"
|
||||||
|
@option-change="updateSettings('mode', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field v-if="modelValue.type !== 'constant'" label="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 {
|
||||||
|
nodeColorTypeOptions: markRaw([
|
||||||
|
{ label: 'Constant', value: 'constant' },
|
||||||
|
{ label: 'Variable', value: 'variable' },
|
||||||
|
{ label: 'Calculated', value: 'calculated' }
|
||||||
|
]),
|
||||||
|
nodeCalculatedColorMethodOptions: markRaw([
|
||||||
|
{ label: 'Degree', value: 'degree' },
|
||||||
|
{ label: 'In degree', value: 'inDegree' },
|
||||||
|
{ label: 'Out degree', value: 'outDegree' }
|
||||||
|
]),
|
||||||
|
сolorAsOptions: markRaw([
|
||||||
|
{ label: 'Continious', value: 'continious' },
|
||||||
|
{ label: 'Categorical', value: 'categorical' }
|
||||||
|
]),
|
||||||
|
сolorscaleDirections: markRaw([
|
||||||
|
{ label: 'Normal', value: 'normal' },
|
||||||
|
{ label: 'Recersed', value: 'reversed' }
|
||||||
|
]),
|
||||||
|
colorSourceUsageOptions: markRaw([
|
||||||
|
{ label: 'Direct', value: 'direct' },
|
||||||
|
{ label: 'Map to', value: 'map_to' }
|
||||||
|
]),
|
||||||
|
defaultColorSettings: {
|
||||||
|
constant: { value: '#1F77B4' },
|
||||||
|
variable: {
|
||||||
|
source: null,
|
||||||
|
sourceUsage: 'map_to',
|
||||||
|
colorscale: null,
|
||||||
|
mode: 'categorical',
|
||||||
|
colorscaleDirection: 'normal'
|
||||||
|
},
|
||||||
|
calculated: {
|
||||||
|
method: 'degree',
|
||||||
|
sourceUsage: 'map_to',
|
||||||
|
colorscale: null,
|
||||||
|
mode: 'continious',
|
||||||
|
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>
|
||||||
118
src/components/Graph/NodeSizeSettings.vue
Normal file
118
src/components/Graph/NodeSizeSettings.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Size">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="nodeSizeTypeOptions"
|
||||||
|
:activeOption="modelValue.type"
|
||||||
|
@option-change="updateSizeType"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
v-if="modelValue.type === 'calculated'"
|
||||||
|
:options="nodeCalculatedSizeMethodOptions"
|
||||||
|
:value="modelValue.method"
|
||||||
|
@change="updateSettings('method', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<template v-if="modelValue.type !== 'constant'">
|
||||||
|
<Field label="Size scale">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.scale"
|
||||||
|
@update="updateSettings('scale', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Size mode">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="nodeSizeModeOptions"
|
||||||
|
:activeOption="modelValue.mode"
|
||||||
|
@option-change="updateSettings('mode', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Minimum size">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<modal
|
<modal
|
||||||
:modalId="name"
|
v-model="show"
|
||||||
class="dialog"
|
class="dialog"
|
||||||
:clickToClose="false"
|
:clickToClose="false"
|
||||||
:contentTransition="{ name: 'loading-dialog' }"
|
:contentTransition="{ name: 'loading-dialog' }"
|
||||||
:overlayTransition="{ name: 'loading-dialog' }"
|
:overlayTransition="{ name: 'loading-dialog' }"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
<close-icon :disabled="loading" @click="$emit('cancel')" />
|
<close-icon :disabled="loading" @click="cancel" />
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<div v-if="loading" class="loading-dialog-body">
|
<div v-if="loading" class="loading-dialog-body">
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
class="secondary"
|
class="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="$emit('cancel')"
|
@click="cancel"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -52,24 +53,33 @@ export default {
|
|||||||
name: 'LoadingDialog',
|
name: 'LoadingDialog',
|
||||||
components: { LoadingIndicator, CloseIcon },
|
components: { LoadingIndicator, CloseIcon },
|
||||||
props: {
|
props: {
|
||||||
|
modelValue: Boolean,
|
||||||
loadingMsg: String,
|
loadingMsg: String,
|
||||||
successMsg: String,
|
successMsg: String,
|
||||||
actionBtnName: String,
|
actionBtnName: String,
|
||||||
name: String,
|
|
||||||
title: String,
|
title: String,
|
||||||
loading: Boolean
|
loading: Boolean
|
||||||
},
|
},
|
||||||
emits: ['cancel', 'action'],
|
emits: ['cancel', 'action', 'update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
show: this.modelValue
|
||||||
|
}
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
modelValue() {
|
||||||
|
this.show = this.modelValue
|
||||||
|
},
|
||||||
loading() {
|
loading() {
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
this.$modal.show(this.name)
|
this.$emit('update:modelValue', true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
cancel() {
|
cancel() {
|
||||||
this.$emit('cancel')
|
this.$emit('cancel')
|
||||||
|
this.$emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/components/svg/graph.vue
Normal file
40
src/components/svg/graph.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
||||||
20
src/components/svg/stop.vue
Normal file
20
src/components/svg/stop.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 4C3 3.44772 3.44772 3 4 3H14C14.5523 3 15 3.44772 15 4V14C15 14.5523 14.5523 15 14 15H4C3.44772 15 3 14.5523 3 14V4Z"
|
||||||
|
fill="#A2B1C6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'StopIcon'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
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
|
||||||
307
src/lib/graphHelper.js
Normal file
307
src/lib/graphHelper.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { COLOR_PICKER_CONSTANTS } from 'react-colorscales'
|
||||||
|
import tinycolor from 'tinycolor2'
|
||||||
|
|
||||||
|
const TYPE_NODE = 0
|
||||||
|
const TYPE_EDGE = 1
|
||||||
|
const DEFAULT_SCALE = COLOR_PICKER_CONSTANTS.DEFAULT_SCALE
|
||||||
|
|
||||||
|
export function buildNodes(graph, dataSources, options) {
|
||||||
|
const docColumn = Object.keys(dataSources)[0] || 'doc'
|
||||||
|
const { objectType, nodeId } = options.structure
|
||||||
|
|
||||||
|
if (objectType && nodeId) {
|
||||||
|
const nodes = dataSources[docColumn]
|
||||||
|
.map(json => JSON.parse(json))
|
||||||
|
.filter(item => item[objectType] === TYPE_NODE)
|
||||||
|
nodes.forEach(node => {
|
||||||
|
graph.addNode(node[nodeId], {
|
||||||
|
data: node,
|
||||||
|
labelColor: options.style.nodes.label.color
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEdges(graph, dataSources, options) {
|
||||||
|
const docColumn = Object.keys(dataSources)[0] || 'doc'
|
||||||
|
const { objectType, edgeSource, edgeTarget } = options.structure
|
||||||
|
|
||||||
|
if (objectType && edgeSource && edgeTarget) {
|
||||||
|
const edges = dataSources[docColumn]
|
||||||
|
.map(json => JSON.parse(json))
|
||||||
|
.filter(item => item[objectType] === TYPE_EDGE)
|
||||||
|
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const source = edge[edgeSource]
|
||||||
|
const target = edge[edgeTarget]
|
||||||
|
if (graph.hasNode(source) && graph.hasNode(target)) {
|
||||||
|
graph.addEdge(source, target, {
|
||||||
|
data: edge,
|
||||||
|
labelColor: options.style.edges.label.color
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getVariabledSizeMethod(mode, source, scale, min)
|
||||||
|
} else {
|
||||||
|
return (attributes, nodeId) =>
|
||||||
|
(attributes.size = Math.max(graph[method](nodeId) * scale, min))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectVariableColorUpdateMethod(source) {
|
||||||
|
return attributes =>
|
||||||
|
(attributes.color = tinycolor(attributes.data[source]).toHexString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateNodeColorMethod(graph, colorSettings) {
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
source,
|
||||||
|
sourceUsage,
|
||||||
|
colorscale,
|
||||||
|
colorscaleDirection,
|
||||||
|
mode,
|
||||||
|
method
|
||||||
|
} = colorSettings
|
||||||
|
if (type === 'constant') {
|
||||||
|
return attributes => (attributes.color = value)
|
||||||
|
} else if (type === 'variable') {
|
||||||
|
return sourceUsage === 'map_to'
|
||||||
|
? getColorMethod(
|
||||||
|
graph,
|
||||||
|
mode,
|
||||||
|
(nodeId, attributes) => attributes.data[source],
|
||||||
|
colorscale,
|
||||||
|
colorscaleDirection,
|
||||||
|
getNodeValueScale
|
||||||
|
)
|
||||||
|
: getDirectVariableColorUpdateMethod(source)
|
||||||
|
} else {
|
||||||
|
return getColorMethod(
|
||||||
|
graph,
|
||||||
|
mode,
|
||||||
|
nodeId => graph[method](nodeId),
|
||||||
|
colorscale,
|
||||||
|
colorscaleDirection,
|
||||||
|
getNodeValueScale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateEdgeColorMethod(graph, colorSettings) {
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
source,
|
||||||
|
sourceUsage,
|
||||||
|
colorscale,
|
||||||
|
colorscaleDirection,
|
||||||
|
mode
|
||||||
|
} = colorSettings
|
||||||
|
if (type === 'constant') {
|
||||||
|
return attributes => (attributes.color = value)
|
||||||
|
} else {
|
||||||
|
return sourceUsage === 'map_to'
|
||||||
|
? getColorMethod(
|
||||||
|
graph,
|
||||||
|
mode,
|
||||||
|
(edgeId, attributes) => attributes.data[source],
|
||||||
|
colorscale,
|
||||||
|
colorscaleDirection,
|
||||||
|
getEdgeValueScale
|
||||||
|
)
|
||||||
|
: getDirectVariableColorUpdateMethod(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariabledSizeMethod(mode, source, scale, min) {
|
||||||
|
if (mode === 'diameter') {
|
||||||
|
return attributes =>
|
||||||
|
(attributes.size = Math.max(
|
||||||
|
(attributes.data[source] / 2) * scale,
|
||||||
|
min / 2
|
||||||
|
))
|
||||||
|
} else if (mode === 'area') {
|
||||||
|
return attributes =>
|
||||||
|
(attributes.size = Math.max(
|
||||||
|
Math.sqrt((attributes.data[source] / 2) * scale),
|
||||||
|
min / 2
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
return attributes =>
|
||||||
|
(attributes.size = Math.max(attributes.data[source] * scale, min))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorMethod(
|
||||||
|
graph,
|
||||||
|
mode,
|
||||||
|
sourceGetter,
|
||||||
|
selectedColorscale,
|
||||||
|
colorscaleDirection,
|
||||||
|
valueScaleGetter
|
||||||
|
) {
|
||||||
|
const valueScale = valueScaleGetter(graph, sourceGetter)
|
||||||
|
let colorscale = selectedColorscale || DEFAULT_SCALE
|
||||||
|
if (colorscaleDirection === 'reversed') {
|
||||||
|
colorscale = [...colorscale].reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'categorical') {
|
||||||
|
const colorMap = Object.fromEntries(
|
||||||
|
valueScale.map((value, index) => [
|
||||||
|
value,
|
||||||
|
colorscale[index % colorscale.length]
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
return (attributes, nodeId) => {
|
||||||
|
const category = sourceGetter(nodeId, attributes)
|
||||||
|
attributes.color = colorMap[category]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const min = valueScale[0]
|
||||||
|
const max = valueScale[valueScale.length - 1]
|
||||||
|
const normalizedColorscale = colorscale.map((color, index) => [
|
||||||
|
index / (colorscale.length - 1),
|
||||||
|
tinycolor(color).toRgb()
|
||||||
|
])
|
||||||
|
return (attributes, nodeId) => {
|
||||||
|
const value = sourceGetter(nodeId, attributes)
|
||||||
|
const normalizedValue = (value - min) / (max - min)
|
||||||
|
if (isNaN(normalizedValue)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exactMatch = normalizedColorscale.find(
|
||||||
|
([value]) => value === normalizedValue
|
||||||
|
)
|
||||||
|
if (exactMatch) {
|
||||||
|
attributes.color = tinycolor(exactMatch[1]).toHexString()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightColorIndex = normalizedColorscale.findIndex(
|
||||||
|
([value]) => value >= normalizedValue
|
||||||
|
)
|
||||||
|
const leftColorIndex = (rightColorIndex || 1) - 1
|
||||||
|
const right = normalizedColorscale[rightColorIndex]
|
||||||
|
const left = normalizedColorscale[leftColorIndex]
|
||||||
|
const interpolationFactor =
|
||||||
|
(normalizedValue - left[0]) / (right[0] - left[0])
|
||||||
|
|
||||||
|
const r0 = left[1].r
|
||||||
|
const g0 = left[1].g
|
||||||
|
const b0 = left[1].b
|
||||||
|
const r1 = right[1].r
|
||||||
|
const g1 = right[1].g
|
||||||
|
const b1 = right[1].b
|
||||||
|
|
||||||
|
attributes.color = tinycolor({
|
||||||
|
r: r0 + interpolationFactor * (r1 - r0),
|
||||||
|
g: g0 + interpolationFactor * (g1 - g0),
|
||||||
|
b: b0 + interpolationFactor * (b1 - b0)
|
||||||
|
}).toHexString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeValueScale(graph, sourceGetter) {
|
||||||
|
const scaleSet = graph.reduceNodes((res, nodeId, attributes) => {
|
||||||
|
res.add(sourceGetter(nodeId, attributes))
|
||||||
|
return res
|
||||||
|
}, new Set())
|
||||||
|
return Array.from(scaleSet).sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeValueScale(graph, sourceGetter) {
|
||||||
|
const scaleSet = graph.reduceEdges((res, edgeId, attributes) => {
|
||||||
|
res.add(sourceGetter(edgeId, attributes))
|
||||||
|
return res
|
||||||
|
}, new Set())
|
||||||
|
return Array.from(scaleSet).sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOptionsFromDataSources(dataSources) {
|
||||||
|
if (!dataSources) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(dataSources).map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getOptionsFromDataSources
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import events from '@/lib/utils/events'
|
|||||||
import migration from './_migrations'
|
import migration from './_migrations'
|
||||||
|
|
||||||
const migrate = migration._migrate
|
const migrate = migration._migrate
|
||||||
|
const myInquiriesKey = 'myInquiries'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
version: 2,
|
version: 2,
|
||||||
|
myInquiriesKey,
|
||||||
getStoredInquiries() {
|
getStoredInquiries() {
|
||||||
let myInquiries = JSON.parse(localStorage.getItem('myInquiries'))
|
let myInquiries = JSON.parse(localStorage.getItem(myInquiriesKey))
|
||||||
if (!myInquiries) {
|
if (!myInquiries) {
|
||||||
const oldInquiries = localStorage.getItem('myQueries')
|
const oldInquiries = localStorage.getItem('myQueries')
|
||||||
if (oldInquiries) {
|
if (oldInquiries) {
|
||||||
@@ -26,7 +28,8 @@ export default {
|
|||||||
const newInquiry = JSON.parse(JSON.stringify(baseInquiry))
|
const newInquiry = JSON.parse(JSON.stringify(baseInquiry))
|
||||||
newInquiry.name = newInquiry.name + ' Copy'
|
newInquiry.name = newInquiry.name + ' Copy'
|
||||||
newInquiry.id = nanoid()
|
newInquiry.id = nanoid()
|
||||||
newInquiry.createdAt = new Date()
|
newInquiry.createdAt = new Date().toJSON()
|
||||||
|
newInquiry.updatedAt = new Date().toJSON()
|
||||||
delete newInquiry.isPredefined
|
delete newInquiry.isPredefined
|
||||||
|
|
||||||
return newInquiry
|
return newInquiry
|
||||||
@@ -38,7 +41,7 @@ export default {
|
|||||||
|
|
||||||
updateStorage(inquiries) {
|
updateStorage(inquiries) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'myInquiries',
|
myInquiriesKey,
|
||||||
JSON.stringify({ version: this.version, inquiries })
|
JSON.stringify({ version: this.version, inquiries })
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default class Tab {
|
|||||||
|
|
||||||
this.isSaved = !!inquiry.id
|
this.isSaved = !!inquiry.id
|
||||||
this.state = state
|
this.state = state
|
||||||
|
this.updatedAt = inquiry.updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute() {
|
async execute() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import LoadView from '@/views/LoadView'
|
|||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import database from '@/lib/database'
|
import database from '@/lib/database'
|
||||||
|
|
||||||
const routes = [
|
export const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Welcome',
|
name: 'Welcome',
|
||||||
|
|||||||
@@ -17,28 +17,33 @@ export default {
|
|||||||
},
|
},
|
||||||
async saveInquiry({ state }, { inquiryTab, newName }) {
|
async saveInquiry({ state }, { inquiryTab, newName }) {
|
||||||
const value = {
|
const value = {
|
||||||
id: inquiryTab.isPredefined ? nanoid() : inquiryTab.id,
|
id: inquiryTab.isPredefined || newName ? nanoid() : inquiryTab.id,
|
||||||
query: inquiryTab.query,
|
query: inquiryTab.query,
|
||||||
viewType: inquiryTab.dataView.mode,
|
viewType: inquiryTab.dataView.mode,
|
||||||
viewOptions: inquiryTab.dataView.getOptionsForSave(),
|
viewOptions: inquiryTab.dataView.getOptionsForSave(),
|
||||||
name: newName || inquiryTab.name
|
name: newName || inquiryTab.name,
|
||||||
|
updatedAt: new Date().toJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get inquiries from local storage
|
// Get inquiries from local storage
|
||||||
const myInquiries = state.inquiries
|
const myInquiries = state.inquiries
|
||||||
|
let inquiryIndex
|
||||||
// Set createdAt
|
// Set createdAt
|
||||||
if (newName) {
|
if (newName) {
|
||||||
value.createdAt = new Date()
|
value.createdAt = new Date().toJSON()
|
||||||
} else {
|
} else {
|
||||||
var inquiryIndex = myInquiries.findIndex(
|
inquiryIndex = myInquiries.findIndex(
|
||||||
oldInquiry => oldInquiry.id === inquiryTab.id
|
oldInquiry => oldInquiry.id === inquiryTab.id
|
||||||
)
|
)
|
||||||
value.createdAt = myInquiries[inquiryIndex].createdAt
|
|
||||||
|
value.createdAt =
|
||||||
|
inquiryIndex !== -1
|
||||||
|
? myInquiries[inquiryIndex].createdAt
|
||||||
|
: new Date().toJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert in inquiries list
|
// Insert in inquiries list
|
||||||
if (newName) {
|
if (newName || inquiryIndex === -1) {
|
||||||
myInquiries.push(value)
|
myInquiries.push(value)
|
||||||
} else {
|
} else {
|
||||||
myInquiries.splice(inquiryIndex, 1, value)
|
myInquiries.splice(inquiryIndex, 1, value)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateTab(state, { tab, newValues }) {
|
updateTab(state, { tab, newValues }) {
|
||||||
const { name, id, query, viewType, viewOptions, isSaved } = newValues
|
const { name, id, query, viewType, viewOptions, isSaved, updatedAt } =
|
||||||
|
newValues
|
||||||
const oldId = tab.id
|
const oldId = tab.id
|
||||||
|
|
||||||
if (id && state.currentTabId === oldId) {
|
if (id && state.currentTabId === oldId) {
|
||||||
@@ -36,6 +37,9 @@ export default {
|
|||||||
// Saved inquiry is not predefined
|
// Saved inquiry is not predefined
|
||||||
delete tab.isPredefined
|
delete tab.isPredefined
|
||||||
}
|
}
|
||||||
|
if (updatedAt) {
|
||||||
|
tab.updatedAt = updatedAt
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTab(state, tab) {
|
deleteTab(state, tab) {
|
||||||
|
|||||||
@@ -10,14 +10,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="nav-buttons">
|
<div id="nav-buttons">
|
||||||
<button
|
<button
|
||||||
v-show="currentInquiry && $route.path === '/workspace'"
|
v-show="currentInquiryTab && $route.path === '/workspace'"
|
||||||
id="save-btn"
|
id="save-btn"
|
||||||
class="primary"
|
class="primary"
|
||||||
:disabled="isSaved"
|
:disabled="isSaved"
|
||||||
@click="checkInquiryBeforeSave"
|
@click="onSave(false)"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-show="currentInquiryTab && $route.path === '/workspace'"
|
||||||
|
id="save-as-btn"
|
||||||
|
class="primary"
|
||||||
|
@click="onSaveAs"
|
||||||
|
>
|
||||||
|
Save as
|
||||||
|
</button>
|
||||||
<button id="create-btn" class="primary" @click="createNewInquiry">
|
<button id="create-btn" class="primary" @click="createNewInquiry">
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
@@ -45,7 +53,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dialog-buttons-container">
|
<div class="dialog-buttons-container">
|
||||||
<button class="secondary" @click="cancelSave">Cancel</button>
|
<button class="secondary" @click="cancelSave">Cancel</button>
|
||||||
<button class="primary" @click="saveInquiry">Save</button>
|
<button class="primary" @click="validateSaveFormAndSaveInquiry">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
|
||||||
|
<!-- Inquiery saving conflict dialog -->
|
||||||
|
<modal
|
||||||
|
modalId="inquiry-conflict"
|
||||||
|
class="dialog"
|
||||||
|
contentStyle="width: 560px;"
|
||||||
|
>
|
||||||
|
<div class="dialog-header">
|
||||||
|
Inquiry saving conflict
|
||||||
|
<close-icon @click="cancelSave" />
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div id="save-note">
|
||||||
|
<img src="~@/assets/images/info.svg" />
|
||||||
|
This inquiry has been modified in the mean time. This can happen if an
|
||||||
|
inquiry is saved in another window or browser tab. Do you want to
|
||||||
|
overwrite that changes or save the current state as a new inquiry?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-buttons-container">
|
||||||
|
<button class="secondary" @click="cancelSave">Cancel</button>
|
||||||
|
<button class="primary" @click="onSave(true)">Overwrite</button>
|
||||||
|
<button class="primary" @click="onSaveAs">Save as new</button>
|
||||||
</div>
|
</div>
|
||||||
</modal>
|
</modal>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -73,25 +108,28 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
currentInquiry() {
|
inquiries() {
|
||||||
|
return this.$store.state.inquiries
|
||||||
|
},
|
||||||
|
currentInquiryTab() {
|
||||||
return this.$store.state.currentTab
|
return this.$store.state.currentTab
|
||||||
},
|
},
|
||||||
isSaved() {
|
isSaved() {
|
||||||
return this.currentInquiry && this.currentInquiry.isSaved
|
return this.currentInquiryTab && this.currentInquiryTab.isSaved
|
||||||
},
|
},
|
||||||
isPredefined() {
|
isPredefined() {
|
||||||
return this.currentInquiry && this.currentInquiry.isPredefined
|
return this.currentInquiryTab && this.currentInquiryTab.isPredefined
|
||||||
},
|
},
|
||||||
runDisabled() {
|
runDisabled() {
|
||||||
return (
|
return (
|
||||||
this.currentInquiry &&
|
this.currentInquiryTab &&
|
||||||
(!this.$store.state.db || !this.currentInquiry.query)
|
(!this.$store.state.db || !this.currentInquiryTab.query)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
eventBus.$on('createNewInquiry', this.createNewInquiry)
|
eventBus.$on('createNewInquiry', this.createNewInquiry)
|
||||||
eventBus.$on('saveInquiry', this.checkInquiryBeforeSave)
|
eventBus.$on('saveInquiry', this.onSave)
|
||||||
document.addEventListener('keydown', this._keyListener)
|
document.addEventListener('keydown', this._keyListener)
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -109,63 +147,84 @@ export default {
|
|||||||
events.send('inquiry.create', null, { auto: false })
|
events.send('inquiry.create', null, { auto: false })
|
||||||
},
|
},
|
||||||
cancelSave() {
|
cancelSave() {
|
||||||
this.$modal.hide('save')
|
|
||||||
eventBus.$off('inquirySaved')
|
|
||||||
},
|
|
||||||
checkInquiryBeforeSave() {
|
|
||||||
this.errorMsg = null
|
this.errorMsg = null
|
||||||
this.name = ''
|
this.name = ''
|
||||||
|
this.$modal.hide('save')
|
||||||
if (storedInquiries.isTabNeedName(this.currentInquiry)) {
|
this.$modal.hide('inquiry-conflict')
|
||||||
this.$modal.show('save')
|
eventBus.$off('inquirySaved')
|
||||||
} else {
|
|
||||||
this.saveInquiry()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async saveInquiry() {
|
onSave(skipConcurrentEditingCheck = false) {
|
||||||
const isNeedName = storedInquiries.isTabNeedName(this.currentInquiry)
|
if (storedInquiries.isTabNeedName(this.currentInquiryTab)) {
|
||||||
if (isNeedName && !this.name) {
|
this.openSaveModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipConcurrentEditingCheck) {
|
||||||
|
const inquiryInStore = this.inquiries.find(
|
||||||
|
inquiry => inquiry.id === this.currentInquiryTab.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
inquiryInStore &&
|
||||||
|
inquiryInStore.updatedAt !== this.currentInquiryTab.updatedAt
|
||||||
|
) {
|
||||||
|
this.$modal.show('inquiry-conflict')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.saveInquiry()
|
||||||
|
},
|
||||||
|
onSaveAs() {
|
||||||
|
this.openSaveModal()
|
||||||
|
},
|
||||||
|
openSaveModal() {
|
||||||
|
this.$modal.hide('inquiry-conflict')
|
||||||
|
this.errorMsg = null
|
||||||
|
this.name = ''
|
||||||
|
this.$modal.show('save')
|
||||||
|
},
|
||||||
|
validateSaveFormAndSaveInquiry() {
|
||||||
|
if (!this.name) {
|
||||||
this.errorMsg = "Inquiry name can't be empty"
|
this.errorMsg = "Inquiry name can't be empty"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const dataSet = this.currentInquiry.result
|
this.saveInquiry()
|
||||||
const tabView = this.currentInquiry.view
|
},
|
||||||
|
async saveInquiry() {
|
||||||
|
const eventName =
|
||||||
|
this.currentInquiryTab.name && this.name
|
||||||
|
? 'inquiry.saveAs'
|
||||||
|
: 'inquiry.save'
|
||||||
|
|
||||||
// Save inquiry
|
// Save inquiry
|
||||||
const value = await this.$store.dispatch('saveInquiry', {
|
const value = await this.$store.dispatch('saveInquiry', {
|
||||||
inquiryTab: this.currentInquiry,
|
inquiryTab: this.currentInquiryTab,
|
||||||
newName: this.name
|
newName: this.name
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update tab in store
|
// Update tab in store
|
||||||
this.$store.commit('updateTab', {
|
this.$store.commit('updateTab', {
|
||||||
tab: this.currentInquiry,
|
tab: this.currentInquiryTab,
|
||||||
newValues: {
|
newValues: {
|
||||||
name: value.name,
|
name: value.name,
|
||||||
id: value.id,
|
id: value.id,
|
||||||
query: value.query,
|
query: value.query,
|
||||||
viewType: value.viewType,
|
viewType: value.viewType,
|
||||||
viewOptions: value.viewOptions,
|
viewOptions: value.viewOptions,
|
||||||
isSaved: true
|
isSaved: true,
|
||||||
|
updatedAt: value.updatedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restore data:
|
// Hide dialogs
|
||||||
// e.g. if we save predefined inquiry the tab will be created again
|
|
||||||
// (because of new id) and
|
|
||||||
// it will be without sql result and has default view - table.
|
|
||||||
// That's why we need to restore data and view
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.currentInquiry.result = dataSet
|
|
||||||
this.currentInquiry.view = tabView
|
|
||||||
})
|
|
||||||
|
|
||||||
// Hide dialog
|
|
||||||
this.$modal.hide('save')
|
this.$modal.hide('save')
|
||||||
|
this.$modal.hide('inquiry-conflict')
|
||||||
|
this.errorMsg = null
|
||||||
|
this.name = ''
|
||||||
|
|
||||||
// Signal about saving
|
// Signal about saving
|
||||||
eventBus.$emit('inquirySaved')
|
eventBus.$emit('inquirySaved')
|
||||||
events.send('inquiry.save')
|
events.send(eventName)
|
||||||
},
|
},
|
||||||
_keyListener(e) {
|
_keyListener(e) {
|
||||||
if (this.$route.path === '/workspace') {
|
if (this.$route.path === '/workspace') {
|
||||||
@@ -173,19 +232,25 @@ export default {
|
|||||||
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
|
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!this.runDisabled) {
|
if (!this.runDisabled) {
|
||||||
this.currentInquiry.execute()
|
this.currentInquiryTab.execute()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save inquiry Ctrl+S
|
// Save inquiry Ctrl+S
|
||||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
if (e.key === 's' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!this.isSaved) {
|
if (!this.isSaved) {
|
||||||
this.checkInquiryBeforeSave()
|
this.onSave()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Save inquiry as Ctrl+Shift+S
|
||||||
|
if (e.key === 'S' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.onSaveAs()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// New (blank) inquiry Ctrl+B
|
// New (blank) inquiry Ctrl+B
|
||||||
if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
|
if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
|||||||
@@ -45,11 +45,16 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
dataSources: Object,
|
dataSources: Object,
|
||||||
initOptions: Object,
|
initOptions: Object,
|
||||||
importToPngEnabled: Boolean,
|
exportToPngEnabled: Boolean,
|
||||||
importToSvgEnabled: Boolean,
|
exportToSvgEnabled: Boolean,
|
||||||
forPivot: Boolean
|
forPivot: Boolean
|
||||||
},
|
},
|
||||||
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
|
emits: [
|
||||||
|
'update:exportToSvgEnabled',
|
||||||
|
'update:exportToHtmlEnabled',
|
||||||
|
'update',
|
||||||
|
'loadingImageCompleted'
|
||||||
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
plotly,
|
plotly,
|
||||||
@@ -102,7 +107,8 @@ export default {
|
|||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
this.$emit('update:importToSvgEnabled', true)
|
this.$emit('update:exportToSvgEnabled', true)
|
||||||
|
this.$emit('update:exportToHtmlEnabled', true)
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.resizeObserver = new ResizeObserver(this.handleResize)
|
this.resizeObserver = new ResizeObserver(this.handleResize)
|
||||||
@@ -161,11 +167,8 @@ export default {
|
|||||||
'text/html'
|
'text/html'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async prepareCopy(type = 'png') {
|
prepareCopy(type = 'png') {
|
||||||
return await chartHelper.getImageDataUrl(
|
return chartHelper.getImageDataUrl(this.$refs.plotlyEditor.$el, type)
|
||||||
this.$refs.plotlyEditor.$el,
|
|
||||||
type
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,631 @@
|
|||||||
|
<template>
|
||||||
|
<div class="plotly_editor">
|
||||||
|
<GraphEditorControls>
|
||||||
|
<PanelMenuWrapper>
|
||||||
|
<Panel group="Structure" name="Graph">
|
||||||
|
<Fold name="Graph">
|
||||||
|
<Field>Choose keys explanation...</Field>
|
||||||
|
<Field label="Object type">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.structure.objectType"
|
||||||
|
@change="updateStructure('objectType', $event)"
|
||||||
|
/>
|
||||||
|
<Field>0 - node; 1 - edge</Field>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Node Id">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.structure.nodeId"
|
||||||
|
@change="updateStructure('nodeId', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Edge source">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.structure.edgeSource"
|
||||||
|
@change="updateStructure('edgeSource', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Edge target">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.structure.edgeTarget"
|
||||||
|
@change="updateStructure('edgeTarget', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Fold>
|
||||||
|
</Panel>
|
||||||
|
<Panel group="Style" name="General">
|
||||||
|
<Fold name="General">
|
||||||
|
<Field label="Background color">
|
||||||
|
<ColorPicker
|
||||||
|
:selectedColor="settings.style.backgroundColor"
|
||||||
|
@color-change="settings.style.backgroundColor = $event"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Fold>
|
||||||
|
</Panel>
|
||||||
|
<Panel group="Style" name="Nodes">
|
||||||
|
<Fold name="Nodes">
|
||||||
|
<Field label="Label">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.style.nodes.label.source"
|
||||||
|
@change="updateNodes('label.source', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="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">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="visibilityOptions"
|
||||||
|
:activeOption="settings.style.edges.showDirection"
|
||||||
|
@option-change="updateEdges('showDirection', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Label">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.style.edges.label.source"
|
||||||
|
@change="updateEdges('label.source', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="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"
|
||||||
|
@change="updateLayout($event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<component
|
||||||
|
:is="layoutSettingsComponentMap[settings.layout.type]"
|
||||||
|
v-if="settings.layout.type !== 'circular'"
|
||||||
|
v-model="settings.layout.options"
|
||||||
|
:keyOptions="keysOptions"
|
||||||
|
@update:model-value="updateLayout(settings.layout.type)"
|
||||||
|
/>
|
||||||
|
</Fold>
|
||||||
|
<template v-if="settings.layout.type === 'forceAtlas2'">
|
||||||
|
<Fold name="Advanced layout settings">
|
||||||
|
<AdvancedForceAtlasLayoutSettings
|
||||||
|
v-model="settings.layout.options"
|
||||||
|
:keyOptions="keysOptions"
|
||||||
|
@update:model-value="updateLayout(settings.layout.type)"
|
||||||
|
/>
|
||||||
|
</Fold>
|
||||||
|
<div class="force-atlas-buttons">
|
||||||
|
<Button variant="secondary" @click="resetFA2LayoutSettings">
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" @click="toggleFA2Layout">
|
||||||
|
<template #node:icon>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
padding: '0 3px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<RunIcon v-if="!fa2Running" />
|
||||||
|
<StopIcon v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ fa2Running ? 'Stop' : 'Start' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Panel>
|
||||||
|
</PanelMenuWrapper>
|
||||||
|
</GraphEditorControls>
|
||||||
|
<div
|
||||||
|
ref="graph"
|
||||||
|
: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 AdvancedForceAtlasLayoutSettings from '@/components/Graph/AdvancedForceAtlasLayoutSettings.vue'
|
||||||
|
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue'
|
||||||
|
import FA2Layout from 'graphology-layout-forceatlas2/worker'
|
||||||
|
import forceAtlas2 from 'graphology-layout-forceatlas2'
|
||||||
|
import RunIcon from '@/components/svg/run.vue'
|
||||||
|
import StopIcon from '@/components/svg/stop.vue'
|
||||||
|
import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image'
|
||||||
|
import {
|
||||||
|
buildNodes,
|
||||||
|
buildEdges,
|
||||||
|
updateNodes,
|
||||||
|
updateEdges
|
||||||
|
} from '@/lib/graphHelper'
|
||||||
|
import Graph from 'graphology'
|
||||||
|
import { circular, random, circlepack } from 'graphology-layout'
|
||||||
|
import Sigma from 'sigma'
|
||||||
|
import seedrandom from 'seedrandom'
|
||||||
|
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'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
GraphEditorControls: applyPureReactInVue(GraphEditorControls),
|
||||||
|
PanelMenuWrapper: applyPureReactInVue(PanelMenuWrapper),
|
||||||
|
Panel: applyPureReactInVue(Panel),
|
||||||
|
PanelSection: applyPureReactInVue(Section),
|
||||||
|
Dropdown: applyPureReactInVue(Dropdown),
|
||||||
|
RadioBlocks: applyPureReactInVue(RadioBlocks),
|
||||||
|
Field: applyPureReactInVue(Field),
|
||||||
|
Fold: applyPureReactInVue(Fold),
|
||||||
|
Button: applyPureReactInVue(Button),
|
||||||
|
ColorPicker: applyPureReactInVue(ColorPicker),
|
||||||
|
RunIcon,
|
||||||
|
StopIcon,
|
||||||
|
RandomLayoutSettings,
|
||||||
|
CirclePackLayoutSettings,
|
||||||
|
NodeColorSettings,
|
||||||
|
NodeSizeSettings,
|
||||||
|
EdgeSizeSettings,
|
||||||
|
EdgeColorSettings,
|
||||||
|
AdvancedForceAtlasLayoutSettings
|
||||||
|
},
|
||||||
|
inject: ['tabLayout'],
|
||||||
|
props: {
|
||||||
|
dataSources: Object,
|
||||||
|
initOptions: Object
|
||||||
|
},
|
||||||
|
emits: ['update'],
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
|
||||||
|
settings: this.initOptions
|
||||||
|
? JSON.parse(JSON.stringify(this.initOptions))
|
||||||
|
: {
|
||||||
|
structure: {
|
||||||
|
nodeId: null,
|
||||||
|
objectType: null,
|
||||||
|
edgeSource: null,
|
||||||
|
edgeTarget: null
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
nodes: {
|
||||||
|
size: {
|
||||||
|
type: 'constant',
|
||||||
|
value: 4
|
||||||
|
},
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layoutOptionsArchive: {
|
||||||
|
random: null,
|
||||||
|
circlepack: null,
|
||||||
|
forceAtlas2: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
records() {
|
||||||
|
if (!this.dataSources) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
this.dataSources[Object.keys(this.dataSources)[0] || 'doc'].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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
dataSources() {
|
||||||
|
if (this.dataSources) {
|
||||||
|
this.buildGraph()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
this.$emit('update')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'settings.structure': {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
this.buildGraph()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tabLayout: {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
if (this.tabLayout.dataView !== 'hidden' && this.renderer) {
|
||||||
|
this.renderer.scheduleRender()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.dataSources) {
|
||||||
|
this.buildGraph()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
buildGraph() {
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.kill()
|
||||||
|
}
|
||||||
|
this.graph.clear()
|
||||||
|
|
||||||
|
buildNodes(this.graph, this.dataSources, this.settings)
|
||||||
|
buildEdges(this.graph, this.dataSources, this.settings)
|
||||||
|
|
||||||
|
// Apply visual settings
|
||||||
|
updateNodes(this.graph, this.settings.style.nodes)
|
||||||
|
updateEdges(this.graph, this.settings.style.edges)
|
||||||
|
|
||||||
|
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' }
|
||||||
|
})
|
||||||
|
if (this.settings.layout.type === 'forceAtlas2') {
|
||||||
|
this.autoRunFA2Layout()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateStructure(attributeName, value) {
|
||||||
|
this.settings.structure[attributeName] = value
|
||||||
|
},
|
||||||
|
updateNodes(attributeName, value) {
|
||||||
|
const attributePath = attributeName.split('.')
|
||||||
|
attributePath.reduce((result, current, index) => {
|
||||||
|
if (index === attributePath.length - 1) {
|
||||||
|
return (result[current] = value)
|
||||||
|
} else {
|
||||||
|
return result[current]
|
||||||
|
}
|
||||||
|
}, this.settings.style.nodes)
|
||||||
|
|
||||||
|
updateNodes(this.graph, {
|
||||||
|
[attributePath[0]]: this.settings.style.nodes[attributePath[0]]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateEdges(attributeName, value) {
|
||||||
|
const attributePath = attributeName.split('.')
|
||||||
|
attributePath.reduce((result, current, index) => {
|
||||||
|
if (index === attributePath.length - 1) {
|
||||||
|
return (result[current] = value)
|
||||||
|
} else {
|
||||||
|
return result[current]
|
||||||
|
}
|
||||||
|
}, this.settings.style.edges)
|
||||||
|
|
||||||
|
updateEdges(this.graph, {
|
||||||
|
[attributePath[0]]: this.settings.style.edges[attributePath[0]]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateLayout(layoutType) {
|
||||||
|
const prevLayout = this.settings.layout.type
|
||||||
|
|
||||||
|
// Change layout type? - restore layout settings or set default settings
|
||||||
|
if (layoutType !== prevLayout) {
|
||||||
|
this.layoutOptionsArchive[prevLayout] = this.settings.layout.options
|
||||||
|
this.settings.layout.options = this.layoutOptionsArchive[layoutType]
|
||||||
|
|
||||||
|
if (!this.settings.layout.options) {
|
||||||
|
if (layoutType === 'forceAtlas2') {
|
||||||
|
this.setRecommendedFA2Settings()
|
||||||
|
} else if (['random', 'circlepack'].includes(layoutType)) {
|
||||||
|
this.settings.layout.options = {
|
||||||
|
seedValue: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.settings.layout.type = layoutType
|
||||||
|
}
|
||||||
|
|
||||||
|
// In any case kill FA2 if it exists
|
||||||
|
if (this.fa2Layout) {
|
||||||
|
if (this.fa2Layout.isRunning()) {
|
||||||
|
this.stopFA2Layout()
|
||||||
|
}
|
||||||
|
this.fa2Layout.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutType === 'circular') {
|
||||||
|
circular.assign(this.graph)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutType === 'random') {
|
||||||
|
random.assign(this.graph, {
|
||||||
|
rng: seedrandom(this.settings.layout.options.seedValue || 1)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutType === 'circlepack') {
|
||||||
|
this.graph.forEachNode(nodeId => {
|
||||||
|
this.graph.updateNode(nodeId, attributes => {
|
||||||
|
const newAttributes = { ...attributes }
|
||||||
|
// Delete old hierarchy attributes
|
||||||
|
Object.keys(newAttributes)
|
||||||
|
.filter(key => key.startsWith('hierarchyAttribute'))
|
||||||
|
.forEach(
|
||||||
|
hierarchyAttributeKey =>
|
||||||
|
delete newAttributes[hierarchyAttributeKey]
|
||||||
|
)
|
||||||
|
// Set new hierarchy attributes
|
||||||
|
this.settings.layout.options.hierarchyAttributes?.forEach(
|
||||||
|
(hierarchyAttribute, index) => {
|
||||||
|
newAttributes['hierarchyAttribute' + index] =
|
||||||
|
attributes.data[hierarchyAttribute]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return newAttributes
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
circlepack.assign(this.graph, {
|
||||||
|
hierarchyAttributes:
|
||||||
|
this.settings.layout.options.hierarchyAttributes?.map(
|
||||||
|
(_, index) => 'hierarchyAttribute' + index
|
||||||
|
) || [],
|
||||||
|
rng: seedrandom(this.settings.layout.options.seedValue || 1)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutType === 'forceAtlas2') {
|
||||||
|
if (
|
||||||
|
!this.graph.someNode(
|
||||||
|
(nodeKey, attributes) =>
|
||||||
|
typeof attributes.x === 'number' &&
|
||||||
|
typeof attributes.y === 'number'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
circular.assign(this.graph)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fa2Layout = markRaw(
|
||||||
|
new FA2Layout(this.graph, {
|
||||||
|
getEdgeWeight: (_, attr) =>
|
||||||
|
this.settings.layout.options.weightSource
|
||||||
|
? attr.data[this.settings.layout.options.weightSource]
|
||||||
|
: 1,
|
||||||
|
settings: this.settings.layout.options
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (layoutType !== prevLayout) {
|
||||||
|
this.autoRunFA2Layout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleFA2Layout() {
|
||||||
|
if (this.fa2Layout.isRunning()) {
|
||||||
|
this.stopFA2Layout()
|
||||||
|
} else {
|
||||||
|
this.fa2Running = true
|
||||||
|
this.fa2Layout.start()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopFA2Layout() {
|
||||||
|
this.fa2Running = false
|
||||||
|
this.fa2Layout.stop()
|
||||||
|
if (this.checkIteration) {
|
||||||
|
this.fa2Layout.worker.removeEventListener(
|
||||||
|
'message',
|
||||||
|
this.checkIteration
|
||||||
|
)
|
||||||
|
this.checkIteration = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
autoRunFA2Layout() {
|
||||||
|
if (this.fa2Layout.isRunning()) {
|
||||||
|
this.stopFA2Layout()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.inferSettings(this.graph)
|
||||||
|
this.settings.layout.options = {
|
||||||
|
initialIterationsAmount: 50,
|
||||||
|
adjustSizes: false,
|
||||||
|
barnesHutOptimize: false,
|
||||||
|
barnesHutTheta: 0.5,
|
||||||
|
edgeWeightInfluence: 0,
|
||||||
|
gravity: 1,
|
||||||
|
linLogMode: false,
|
||||||
|
outboundAttractionDistribution: false,
|
||||||
|
scalingRatio: 1,
|
||||||
|
slowDown: 1,
|
||||||
|
strongGravityMode: false,
|
||||||
|
...sensibleSettings
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[Infinity, -Infinity].includes(this.settings.layout.options.slowDown)
|
||||||
|
) {
|
||||||
|
this.settings.layout.options.slowDown = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetFA2LayoutSettings() {
|
||||||
|
if (this.initOptions?.layout.type === 'forceAtlas2') {
|
||||||
|
this.settings.layout = JSON.parse(
|
||||||
|
JSON.stringify(this.initOptions.layout)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.setRecommendedFA2Settings()
|
||||||
|
}
|
||||||
|
this.updateLayout(this.settings.layout.type)
|
||||||
|
},
|
||||||
|
saveAsPng() {
|
||||||
|
return downloadAsPNG(this.renderer, {
|
||||||
|
backgroundColor: this.settings.style.backgroundColor
|
||||||
|
})
|
||||||
|
},
|
||||||
|
prepareCopy() {
|
||||||
|
return drawOnCanvas(this.renderer, {
|
||||||
|
backgroundColor: this.settings.style.backgroundColor
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plotly_editor > div {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.customPickerContainer) {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.force-atlas-buttons {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-atlas-buttons :deep(button) {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
102
src/views/MainView/Workspace/Tabs/Tab/DataView/Graph/index.vue
Normal file
102
src/views/MainView/Workspace/Tabs/Tab/DataView/Graph/index.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="graphContainer" class="chart-container">
|
||||||
|
<div v-show="!dataSources" class="warning chart-warning">
|
||||||
|
There is no data to build a graph. Run your SQL query and make sure the
|
||||||
|
result is not empty.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="graph"
|
||||||
|
:style="{
|
||||||
|
height: !dataSources ? 'calc(100% - 40px)' : '100%'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<GraphEditor
|
||||||
|
ref="graphEditor"
|
||||||
|
:dataSources="dataSources"
|
||||||
|
:initOptions="initOptions"
|
||||||
|
@update="$emit('update')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import 'react-chart-editor/lib/react-chart-editor.css'
|
||||||
|
import events from '@/lib/utils/events'
|
||||||
|
import GraphEditor from './GraphEditor.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Graph',
|
||||||
|
components: { GraphEditor },
|
||||||
|
props: {
|
||||||
|
dataSources: Object,
|
||||||
|
initOptions: Object,
|
||||||
|
exportToPngEnabled: Boolean,
|
||||||
|
exportToSvgEnabled: Boolean,
|
||||||
|
exportToHtmlEnabled: Boolean
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'update:exportToSvgEnabled',
|
||||||
|
'update:exportToHtmlEnabled',
|
||||||
|
'update',
|
||||||
|
'loadingImageCompleted'
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resizeObserver: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.$emit('update:exportToSvgEnabled', false)
|
||||||
|
this.$emit('update:exportToHtmlEnabled', false)
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.resizeObserver = new ResizeObserver(this.handleResize)
|
||||||
|
this.resizeObserver.observe(this.$refs.graphContainer)
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.resizeObserver.unobserve(this.$refs.graphContainer)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getOptionsForSave() {
|
||||||
|
return this.$refs.graphEditor.settings
|
||||||
|
},
|
||||||
|
async saveAsPng() {
|
||||||
|
await this.$refs.graphEditor.saveAsPng()
|
||||||
|
this.$emit('loadingImageCompleted')
|
||||||
|
},
|
||||||
|
async prepareCopy() {
|
||||||
|
return await this.$refs.graphEditor.prepareCopy()
|
||||||
|
},
|
||||||
|
async handleResize() {
|
||||||
|
const renderer = this.$refs.graphEditor.renderer
|
||||||
|
if (renderer) {
|
||||||
|
renderer.refresh()
|
||||||
|
renderer.getCamera().setState({ x: 0.5, y: 0.5 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-warning {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
min-height: 242px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.editor_controls .sidebar__item:before) {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -46,14 +46,15 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
dataSources: Object,
|
dataSources: Object,
|
||||||
initOptions: Object,
|
initOptions: Object,
|
||||||
importToPngEnabled: Boolean,
|
exportToPngEnabled: Boolean,
|
||||||
importToSvgEnabled: Boolean
|
exportToSvgEnabled: Boolean
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'loadingImageCompleted',
|
'loadingImageCompleted',
|
||||||
'update',
|
'update',
|
||||||
'update:importToSvgEnabled',
|
'update:exportToSvgEnabled',
|
||||||
'update:importToPngEnabled'
|
'update:exportToPngEnabled',
|
||||||
|
'update:exportToHtmlEnabled'
|
||||||
],
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -110,11 +111,11 @@ export default {
|
|||||||
immediate: true,
|
immediate: true,
|
||||||
handler() {
|
handler() {
|
||||||
this.$emit(
|
this.$emit(
|
||||||
'update:importToPngEnabled',
|
'update:exportToPngEnabled',
|
||||||
this.pivotOptions.rendererName !== 'TSV Export'
|
this.pivotOptions.rendererName !== 'TSV Export'
|
||||||
)
|
)
|
||||||
this.$emit(
|
this.$emit(
|
||||||
'update:importToSvgEnabled',
|
'update:exportToSvgEnabled',
|
||||||
this.viewStandartChart || this.viewCustomChart
|
this.viewStandartChart || this.viewCustomChart
|
||||||
)
|
)
|
||||||
events.send('viz_pivot.render', null, {
|
events.send('viz_pivot.render', null, {
|
||||||
@@ -126,6 +127,9 @@ export default {
|
|||||||
this.show()
|
this.show()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
created() {
|
||||||
|
this.$emit('update:exportToHtmlEnabled', true)
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.show()
|
this.show()
|
||||||
// We need to detect resizing because plotly doesn't resize when resize its container
|
// We need to detect resizing because plotly doesn't resize when resize its container
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
<component
|
<component
|
||||||
:is="mode"
|
:is="mode"
|
||||||
ref="viewComponent"
|
ref="viewComponent"
|
||||||
v-model:importToPngEnabled="importToPngEnabled"
|
v-model:exportToPngEnabled="exportToPngEnabled"
|
||||||
v-model:importToSvgEnabled="importToSvgEnabled"
|
v-model:exportToSvgEnabled="exportToSvgEnabled"
|
||||||
:initOptions="mode === initMode ? initOptions : undefined"
|
v-model:exportToHtmlEnabled="exportToHtmlEnabled"
|
||||||
|
:initOptions="initOptionsByMode[mode]"
|
||||||
:data-sources="dataSource"
|
:data-sources="dataSource"
|
||||||
@loading-image-completed="loadingImage = false"
|
@loading-image-completed="loadingImage = false"
|
||||||
@update="$emit('update')"
|
@update="$emit('update')"
|
||||||
@@ -30,11 +31,19 @@
|
|||||||
>
|
>
|
||||||
<pivot-icon />
|
<pivot-icon />
|
||||||
</icon-button>
|
</icon-button>
|
||||||
|
<icon-button
|
||||||
|
:active="mode === 'graph'"
|
||||||
|
tooltip="Switch to graph"
|
||||||
|
tooltipPosition="top-left"
|
||||||
|
@click="mode = 'graph'"
|
||||||
|
>
|
||||||
|
<graph-icon />
|
||||||
|
</icon-button>
|
||||||
|
|
||||||
<div class="side-tool-bar-divider" />
|
<div class="side-tool-bar-divider" />
|
||||||
|
|
||||||
<icon-button
|
<icon-button
|
||||||
:disabled="!importToPngEnabled || loadingImage"
|
:disabled="!exportToPngEnabled || loadingImage"
|
||||||
:loading="loadingImage"
|
:loading="loadingImage"
|
||||||
tooltip="Save as PNG image"
|
tooltip="Save as PNG image"
|
||||||
tooltipPosition="top-left"
|
tooltipPosition="top-left"
|
||||||
@@ -44,7 +53,7 @@
|
|||||||
</icon-button>
|
</icon-button>
|
||||||
<icon-button
|
<icon-button
|
||||||
ref="svgExportBtn"
|
ref="svgExportBtn"
|
||||||
:disabled="!importToSvgEnabled"
|
:disabled="!exportToSvgEnabled"
|
||||||
tooltip="Save as SVG"
|
tooltip="Save as SVG"
|
||||||
tooltipPosition="top-left"
|
tooltipPosition="top-left"
|
||||||
@click="saveAsSvg"
|
@click="saveAsSvg"
|
||||||
@@ -54,6 +63,7 @@
|
|||||||
|
|
||||||
<icon-button
|
<icon-button
|
||||||
ref="htmlExportBtn"
|
ref="htmlExportBtn"
|
||||||
|
:disabled="!exportToHtmlEnabled"
|
||||||
tooltip="Save as HTML"
|
tooltip="Save as HTML"
|
||||||
tooltipPosition="top-left"
|
tooltipPosition="top-left"
|
||||||
@click="saveAsHtml"
|
@click="saveAsHtml"
|
||||||
@@ -72,10 +82,10 @@
|
|||||||
</side-tool-bar>
|
</side-tool-bar>
|
||||||
|
|
||||||
<loading-dialog
|
<loading-dialog
|
||||||
|
v-model="showLoadingDialog"
|
||||||
loadingMsg="Rendering the visualisation..."
|
loadingMsg="Rendering the visualisation..."
|
||||||
successMsg="Image is ready"
|
successMsg="Image is ready"
|
||||||
actionBtnName="Copy"
|
actionBtnName="Copy"
|
||||||
name="prepareCopy"
|
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
:loading="preparingCopy"
|
:loading="preparingCopy"
|
||||||
@action="copyToClipboard"
|
@action="copyToClipboard"
|
||||||
@@ -85,18 +95,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Chart from './Chart'
|
import Chart from './Chart/index.vue'
|
||||||
import Pivot from './Pivot'
|
import Pivot from './Pivot/index.vue'
|
||||||
|
import Graph from './Graph/index.vue'
|
||||||
import SideToolBar from '../SideToolBar'
|
import SideToolBar from '../SideToolBar'
|
||||||
import IconButton from '@/components/IconButton'
|
import IconButton from '@/components/IconButton'
|
||||||
import ChartIcon from '@/components/svg/chart'
|
import ChartIcon from '@/components/svg/chart'
|
||||||
import PivotIcon from '@/components/svg/pivot'
|
import PivotIcon from '@/components/svg/pivot'
|
||||||
|
import GraphIcon from '@/components/svg/graph.vue'
|
||||||
import HtmlIcon from '@/components/svg/html'
|
import HtmlIcon from '@/components/svg/html'
|
||||||
import ExportToSvgIcon from '@/components/svg/exportToSvg'
|
import ExportToSvgIcon from '@/components/svg/exportToSvg'
|
||||||
import PngIcon from '@/components/svg/png'
|
import PngIcon from '@/components/svg/png'
|
||||||
import ClipboardIcon from '@/components/svg/clipboard'
|
import ClipboardIcon from '@/components/svg/clipboard'
|
||||||
import cIo from '@/lib/utils/clipboardIo'
|
import cIo from '@/lib/utils/clipboardIo'
|
||||||
import loadingDialog from '@/components/LoadingDialog'
|
import loadingDialog from '@/components/LoadingDialog.vue'
|
||||||
import time from '@/lib/utils/time'
|
import time from '@/lib/utils/time'
|
||||||
import events from '@/lib/utils/events'
|
import events from '@/lib/utils/events'
|
||||||
|
|
||||||
@@ -105,10 +117,12 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
Chart,
|
Chart,
|
||||||
Pivot,
|
Pivot,
|
||||||
|
Graph,
|
||||||
SideToolBar,
|
SideToolBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
PivotIcon,
|
PivotIcon,
|
||||||
|
GraphIcon,
|
||||||
ExportToSvgIcon,
|
ExportToSvgIcon,
|
||||||
PngIcon,
|
PngIcon,
|
||||||
HtmlIcon,
|
HtmlIcon,
|
||||||
@@ -124,12 +138,19 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mode: this.initMode || 'chart',
|
mode: this.initMode || 'chart',
|
||||||
importToPngEnabled: true,
|
exportToPngEnabled: true,
|
||||||
importToSvgEnabled: true,
|
exportToSvgEnabled: true,
|
||||||
|
exportToHtmlEnabled: true,
|
||||||
loadingImage: false,
|
loadingImage: false,
|
||||||
copyingImage: false,
|
copyingImage: false,
|
||||||
preparingCopy: false,
|
preparingCopy: false,
|
||||||
dataToCopy: null
|
dataToCopy: null,
|
||||||
|
initOptionsByMode: {
|
||||||
|
chart: this.initMode === 'chart' ? this.initOptions : null,
|
||||||
|
pivot: this.initMode === 'pivot' ? this.initOptions : null,
|
||||||
|
graph: this.initMode === 'graph' ? this.initOptions : null
|
||||||
|
},
|
||||||
|
showLoadingDialog: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -138,9 +159,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
mode() {
|
mode(newMode, oldMode) {
|
||||||
this.$emit('update')
|
this.$emit('update')
|
||||||
this.importToPngEnabled = true
|
this.exportToPngEnabled = true
|
||||||
|
this.initOptionsByMode[oldMode] = this.getOptionsForSave()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -170,14 +192,13 @@ export default {
|
|||||||
async prepareCopy() {
|
async prepareCopy() {
|
||||||
if ('ClipboardItem' in window) {
|
if ('ClipboardItem' in window) {
|
||||||
this.preparingCopy = true
|
this.preparingCopy = true
|
||||||
this.$modal.show('prepareCopy')
|
this.showLoadingDialog = true
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
|
|
||||||
await time.sleep(0)
|
await time.sleep(0)
|
||||||
this.dataToCopy = await this.$refs.viewComponent.prepareCopy()
|
this.dataToCopy = await this.$refs.viewComponent.prepareCopy()
|
||||||
const t1 = performance.now()
|
const t1 = performance.now()
|
||||||
if (t1 - t0 < 950) {
|
if (t1 - t0 < 950) {
|
||||||
this.$modal.hide('prepareCopy')
|
|
||||||
this.copyToClipboard()
|
this.copyToClipboard()
|
||||||
} else {
|
} else {
|
||||||
this.preparingCopy = false
|
this.preparingCopy = false
|
||||||
@@ -190,14 +211,13 @@ export default {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async copyToClipboard() {
|
copyToClipboard() {
|
||||||
cIo.copyImage(this.dataToCopy)
|
cIo.copyImage(this.dataToCopy)
|
||||||
this.$modal.hide('prepareCopy')
|
this.showLoadingDialog = false
|
||||||
this.exportSignal('clipboard')
|
this.exportSignal('clipboard')
|
||||||
},
|
},
|
||||||
cancelCopy() {
|
cancelCopy() {
|
||||||
this.dataToCopy = null
|
this.dataToCopy = null
|
||||||
this.$modal.hide('prepareCopy')
|
|
||||||
},
|
},
|
||||||
|
|
||||||
saveAsSvg() {
|
saveAsSvg() {
|
||||||
|
|||||||
@@ -80,10 +80,10 @@
|
|||||||
</side-tool-bar>
|
</side-tool-bar>
|
||||||
|
|
||||||
<loading-dialog
|
<loading-dialog
|
||||||
|
v-model="showLoadingDialog"
|
||||||
loadingMsg="Building CSV..."
|
loadingMsg="Building CSV..."
|
||||||
successMsg="CSV is ready"
|
successMsg="CSV is ready"
|
||||||
actionBtnName="Copy"
|
actionBtnName="Copy"
|
||||||
name="prepareCSVCopy"
|
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
:loading="preparingCopy"
|
:loading="preparingCopy"
|
||||||
@action="copyToClipboard"
|
@action="copyToClipboard"
|
||||||
@@ -190,7 +190,8 @@ export default {
|
|||||||
viewRecord: false,
|
viewRecord: false,
|
||||||
defaultPage: 1,
|
defaultPage: 1,
|
||||||
defaultSelectedCell: null,
|
defaultSelectedCell: null,
|
||||||
enableTeleport: this.$store.state.isWorkspaceVisible
|
enableTeleport: this.$store.state.isWorkspaceVisible,
|
||||||
|
showLoadingDialog: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -264,14 +265,13 @@ export default {
|
|||||||
|
|
||||||
if ('ClipboardItem' in window) {
|
if ('ClipboardItem' in window) {
|
||||||
this.preparingCopy = true
|
this.preparingCopy = true
|
||||||
this.$modal.show('prepareCSVCopy')
|
this.showLoadingDialog = true
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
|
|
||||||
await time.sleep(0)
|
await time.sleep(0)
|
||||||
this.dataToCopy = csv.serialize(this.result)
|
this.dataToCopy = csv.serialize(this.result)
|
||||||
const t1 = performance.now()
|
const t1 = performance.now()
|
||||||
if (t1 - t0 < 950) {
|
if (t1 - t0 < 950) {
|
||||||
this.$modal.hide('prepareCSVCopy')
|
|
||||||
this.copyToClipboard()
|
this.copyToClipboard()
|
||||||
} else {
|
} else {
|
||||||
this.preparingCopy = false
|
this.preparingCopy = false
|
||||||
@@ -287,12 +287,11 @@ export default {
|
|||||||
|
|
||||||
copyToClipboard() {
|
copyToClipboard() {
|
||||||
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
|
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
|
||||||
this.$modal.hide('prepareCSVCopy')
|
this.showLoadingDialog = false
|
||||||
},
|
},
|
||||||
|
|
||||||
cancelCopy() {
|
cancelCopy() {
|
||||||
this.dataToCopy = null
|
this.dataToCopy = null
|
||||||
this.$modal.hide('prepareCSVCopy')
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleViewValuePanel() {
|
toggleViewValuePanel() {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ import Splitpanes from '@/components/Splitpanes'
|
|||||||
import SqlEditor from './SqlEditor'
|
import SqlEditor from './SqlEditor'
|
||||||
import DataView from './DataView'
|
import DataView from './DataView'
|
||||||
import RunResult from './RunResult'
|
import RunResult from './RunResult'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick, computed } from 'vue'
|
||||||
|
|
||||||
import events from '@/lib/utils/events'
|
import events from '@/lib/utils/events'
|
||||||
|
|
||||||
@@ -80,6 +80,11 @@ export default {
|
|||||||
RunResult,
|
RunResult,
|
||||||
Splitpanes
|
Splitpanes
|
||||||
},
|
},
|
||||||
|
provide() {
|
||||||
|
return {
|
||||||
|
tabLayout: computed(() => this.tab.layout)
|
||||||
|
}
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
tab: Object
|
tab: Object
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import sinon from 'sinon'
|
import sinon from 'sinon'
|
||||||
import { shallowMount } from '@vue/test-utils'
|
import { shallowMount, mount } from '@vue/test-utils'
|
||||||
import { createStore } from 'vuex'
|
import { createStore } from 'vuex'
|
||||||
import App from '@/App'
|
import App from '@/App.vue'
|
||||||
import storedInquiries from '@/lib/storedInquiries'
|
import storedInquiries from '@/lib/storedInquiries'
|
||||||
|
import actions from '@/store/actions'
|
||||||
import mutations from '@/store/mutations'
|
import mutations from '@/store/mutations'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { routes } from '@/router'
|
||||||
|
|
||||||
describe('App.vue', () => {
|
describe('App.vue', () => {
|
||||||
|
let clock
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clock = sinon.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
sinon.restore()
|
sinon.restore()
|
||||||
})
|
})
|
||||||
@@ -59,4 +68,167 @@ describe('App.vue', () => {
|
|||||||
{ id: 3, name: 'bar' }
|
{ id: 3, name: 'bar' }
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Updates store when inquirires change in local storage', async () => {
|
||||||
|
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
|
||||||
|
{ id: 1, name: 'foo' },
|
||||||
|
{ id: 2, name: 'baz' },
|
||||||
|
{ id: 3, name: 'bar' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
predefinedInquiries: [],
|
||||||
|
inquiries: []
|
||||||
|
}
|
||||||
|
const store = createStore({ state, mutations })
|
||||||
|
shallowMount(App, {
|
||||||
|
global: { stubs: ['router-view'], plugins: [store] }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(state.inquiries).to.eql([
|
||||||
|
{ id: 1, name: 'foo' },
|
||||||
|
{ id: 2, name: 'baz' },
|
||||||
|
{ id: 3, name: 'bar' }
|
||||||
|
])
|
||||||
|
|
||||||
|
storedInquiries.getStoredInquiries.returns([
|
||||||
|
{ id: 1, name: 'foo' },
|
||||||
|
{ id: 3, name: 'bar' }
|
||||||
|
])
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: 'myInquiries' }))
|
||||||
|
expect(state.inquiries).to.eql([
|
||||||
|
{ id: 1, name: 'foo' },
|
||||||
|
{ id: 3, name: 'bar' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ describe('SQLite extensions', function () {
|
|||||||
WHERE ip.id <= p.id
|
WHERE ip.id <= p.id
|
||||||
) AS path
|
) AS path
|
||||||
FROM tmp, json_each(filename_array) AS p
|
FROM tmp, json_each(filename_array) AS p
|
||||||
WHERE p.id > 1 -- because the filenames start with the separator
|
WHERE p.key > 0 -- because the filenames start with the separator
|
||||||
`)
|
`)
|
||||||
expect(actual.values).to.eql({
|
expect(actual.values).to.eql({
|
||||||
path: [
|
path: [
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('storedInquiries.js', () => {
|
|||||||
query: 'SELECT * from foo',
|
query: 'SELECT * from foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: [],
|
viewOptions: [],
|
||||||
createdAt: new Date(2021, 0, 1),
|
createdAt: new Date(2021, 0, 1).toJSON(),
|
||||||
isPredefined: true
|
isPredefined: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,8 @@ describe('storedInquiries.js', () => {
|
|||||||
expect(copy).to.have.property('query').which.equal(base.query)
|
expect(copy).to.have.property('query').which.equal(base.query)
|
||||||
expect(copy).to.have.property('viewType').which.equal(base.viewType)
|
expect(copy).to.have.property('viewType').which.equal(base.viewType)
|
||||||
expect(copy).to.have.property('viewOptions').which.eql(base.viewOptions)
|
expect(copy).to.have.property('viewOptions').which.eql(base.viewOptions)
|
||||||
expect(copy).to.have.property('createdAt').which.within(now, nowPlusMinute)
|
expect(copy).to.have.property('createdAt')
|
||||||
|
expect(new Date(copy.createdAt)).within(now, nowPlusMinute)
|
||||||
expect(copy).to.not.have.property('isPredefined')
|
expect(copy).to.not.have.property('isPredefined')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe('tab.js', () => {
|
|||||||
query: undefined,
|
query: undefined,
|
||||||
viewOptions: undefined,
|
viewOptions: undefined,
|
||||||
isPredefined: undefined,
|
isPredefined: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
result: null,
|
result: null,
|
||||||
isGettingResults: false,
|
isGettingResults: false,
|
||||||
@@ -42,7 +43,8 @@ describe('tab.js', () => {
|
|||||||
viewType: 'pivot',
|
viewType: 'pivot',
|
||||||
viewOptions: 'this is view options object',
|
viewOptions: 'this is view options object',
|
||||||
name: 'Foo inquiry',
|
name: 'Foo inquiry',
|
||||||
createdAt: '2022-12-05T18:30:30'
|
createdAt: '2022-12-05T18:30:30',
|
||||||
|
updatedAt: '2022-12-06T18:30:30'
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTab = new Tab(state, inquiry)
|
const newTab = new Tab(state, inquiry)
|
||||||
@@ -53,6 +55,7 @@ describe('tab.js', () => {
|
|||||||
query: 'SELECT * from foo',
|
query: 'SELECT * from foo',
|
||||||
viewOptions: 'this is view options object',
|
viewOptions: 'this is view options object',
|
||||||
isPredefined: undefined,
|
isPredefined: undefined,
|
||||||
|
updatedAt: '2022-12-06T18:30:30',
|
||||||
viewType: 'pivot',
|
viewType: 'pivot',
|
||||||
result: null,
|
result: null,
|
||||||
isGettingResults: false,
|
isGettingResults: false,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ describe('actions', () => {
|
|||||||
tempName: 'Untitled',
|
tempName: 'Untitled',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: undefined,
|
viewOptions: undefined,
|
||||||
isSaved: false
|
isSaved: false,
|
||||||
|
updatedAt: undefined
|
||||||
})
|
})
|
||||||
expect(state.untitledLastIndex).to.equal(1)
|
expect(state.untitledLastIndex).to.equal(1)
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ describe('actions', () => {
|
|||||||
tempName: 'Untitled 1',
|
tempName: 'Untitled 1',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: undefined,
|
viewOptions: undefined,
|
||||||
isSaved: false
|
isSaved: false,
|
||||||
|
updatedAt: undefined
|
||||||
})
|
})
|
||||||
expect(state.untitledLastIndex).to.equal(2)
|
expect(state.untitledLastIndex).to.equal(2)
|
||||||
})
|
})
|
||||||
@@ -40,16 +42,16 @@ describe('actions', () => {
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
untitledLastIndex: 0
|
untitledLastIndex: 0
|
||||||
}
|
}
|
||||||
const tab = {
|
const inquiry = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'test',
|
name: 'test',
|
||||||
query: 'SELECT * from foo',
|
query: 'SELECT * from foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: 'an object with view options',
|
viewOptions: 'an object with view options',
|
||||||
isSaved: true
|
updatedAt: '2025-05-16T20:15:00Z'
|
||||||
}
|
}
|
||||||
await addTab({ state }, tab)
|
await addTab({ state }, inquiry)
|
||||||
expect(state.tabs[0]).to.include(tab)
|
expect(state.tabs[0]).to.include(inquiry)
|
||||||
expect(state.untitledLastIndex).to.equal(0)
|
expect(state.untitledLastIndex).to.equal(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,21 +168,26 @@ describe('actions', () => {
|
|||||||
newName: 'foo'
|
newName: 'foo'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
expect(value.id).to.equal(tab.id)
|
expect(value.id).not.to.equal(tab.id)
|
||||||
expect(value.name).to.equal('foo')
|
expect(value.name).to.equal('foo')
|
||||||
expect(value.query).to.equal(tab.query)
|
expect(value.query).to.equal(tab.query)
|
||||||
expect(value.viewOptions).to.eql(['chart'])
|
expect(value.viewOptions).to.eql(['chart'])
|
||||||
expect(value).to.have.property('createdAt').which.within(now, nowPlusMinute)
|
expect(value).to.have.property('createdAt')
|
||||||
|
expect(new Date(value.createdAt)).within(now, nowPlusMinute)
|
||||||
|
expect(new Date(value.updatedAt)).within(now, nowPlusMinute)
|
||||||
expect(state.inquiries).to.eql([value])
|
expect(state.inquiries).to.eql([value])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('save updates existing inquiry in the storage', async () => {
|
it('saveInquiry updates existing inquiry in the storage', async () => {
|
||||||
|
const now = new Date()
|
||||||
|
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
|
||||||
const tab = {
|
const tab = {
|
||||||
id: 1,
|
id: 1,
|
||||||
query: 'select * from foo',
|
query: 'select * from foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: [],
|
viewOptions: [],
|
||||||
name: null,
|
name: 'foo',
|
||||||
|
updatedAt: '2025-05-16T20:15:00Z',
|
||||||
dataView: {
|
dataView: {
|
||||||
getOptionsForSave() {
|
getOptionsForSave() {
|
||||||
return ['chart']
|
return ['chart']
|
||||||
@@ -189,34 +196,34 @@ describe('actions', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
inquiries: [],
|
inquiries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
query: 'select * from foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
name: 'foo',
|
||||||
|
createdAt: '2025-05-15T16:30:00Z',
|
||||||
|
updatedAt: '2025-05-16T20:15:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
tabs: [tab]
|
tabs: [tab]
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = await saveInquiry(
|
tab.query = 'select * from bar'
|
||||||
{ state },
|
await saveInquiry({ state }, { inquiryTab: tab, newName: '' })
|
||||||
{
|
|
||||||
inquiryTab: tab,
|
|
||||||
newName: 'foo'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
tab.name = 'foo'
|
|
||||||
tab.query = 'select * from foo'
|
|
||||||
await saveInquiry({ state }, { inquiryTab: tab })
|
|
||||||
const inquiries = state.inquiries
|
const inquiries = state.inquiries
|
||||||
const second = inquiries[0]
|
const updatedTab = inquiries[0]
|
||||||
expect(inquiries).has.lengthOf(1)
|
expect(inquiries).has.lengthOf(1)
|
||||||
expect(second.id).to.equal(first.id)
|
expect(updatedTab.id).to.equal(updatedTab.id)
|
||||||
expect(second.name).to.equal(first.name)
|
expect(updatedTab.name).to.equal(updatedTab.name)
|
||||||
expect(second.query).to.equal(tab.query)
|
expect(updatedTab.query).to.equal(tab.query)
|
||||||
expect(second.viewOptions).to.eql(['chart'])
|
expect(updatedTab.viewOptions).to.eql(['chart'])
|
||||||
expect(new Date(second.createdAt).getTime()).to.equal(
|
expect(updatedTab.createdAt).to.equal('2025-05-15T16:30:00Z')
|
||||||
first.createdAt.getTime()
|
expect(new Date(updatedTab.updatedAt)).to.be.within(now, nowPlusMinute)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("save adds a new inquiry with new id if it's based on predefined inquiry", async () => {
|
it("saveInquiry adds a new inquiry with new id if it's based on predefined inquiry", async () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
|
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
|
||||||
const tab = {
|
const tab = {
|
||||||
@@ -252,6 +259,95 @@ describe('actions', () => {
|
|||||||
expect(inquiries[0].name).to.equal('foo')
|
expect(inquiries[0].name).to.equal('foo')
|
||||||
expect(inquiries[0].query).to.equal(tab.query)
|
expect(inquiries[0].query).to.equal(tab.query)
|
||||||
expect(inquiries[0].viewOptions).to.eql(['chart'])
|
expect(inquiries[0].viewOptions).to.eql(['chart'])
|
||||||
|
expect(new Date(inquiries[0].updatedAt)).to.be.within(now, nowPlusMinute)
|
||||||
expect(new Date(inquiries[0].createdAt)).to.be.within(now, nowPlusMinute)
|
expect(new Date(inquiries[0].createdAt)).to.be.within(now, nowPlusMinute)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('saveInquiry adds new inquiry if newName is provided', async () => {
|
||||||
|
const now = new Date()
|
||||||
|
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
|
||||||
|
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
query: 'select * from foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
name: 'foo',
|
||||||
|
updatedAt: '2025-05-16T20:15:00Z',
|
||||||
|
dataView: {
|
||||||
|
getOptionsForSave() {
|
||||||
|
return ['chart']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inquiry = {
|
||||||
|
id: 1,
|
||||||
|
query: 'select * from foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
name: 'foo',
|
||||||
|
createdAt: '2025-05-15T16:30:00Z',
|
||||||
|
updatedAt: '2025-05-16T20:15:00Z'
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
inquiries: [inquiry],
|
||||||
|
tabs: [tab]
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await saveInquiry(
|
||||||
|
{ state },
|
||||||
|
{
|
||||||
|
inquiryTab: tab,
|
||||||
|
newName: 'foo_new'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(value.id).not.to.equal(tab.id)
|
||||||
|
expect(value.name).to.equal('foo_new')
|
||||||
|
expect(value.query).to.equal(tab.query)
|
||||||
|
expect(value.viewOptions).to.eql(['chart'])
|
||||||
|
expect(value).to.have.property('createdAt')
|
||||||
|
expect(new Date(value.createdAt)).within(now, nowPlusMinute)
|
||||||
|
expect(new Date(value.updatedAt)).within(now, nowPlusMinute)
|
||||||
|
expect(state.inquiries).to.eql([inquiry, value])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveInquiry adds new inquiry if the inquiry is not in the storeage anymore', async () => {
|
||||||
|
const now = new Date()
|
||||||
|
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
|
||||||
|
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
query: 'select * from foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
name: 'foo',
|
||||||
|
updatedAt: '2025-05-16T20:15:00Z',
|
||||||
|
dataView: {
|
||||||
|
getOptionsForSave() {
|
||||||
|
return ['chart']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
inquiries: [],
|
||||||
|
tabs: [tab]
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await saveInquiry(
|
||||||
|
{ state },
|
||||||
|
{
|
||||||
|
inquiryTab: tab,
|
||||||
|
newName: ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(value.id).to.equal(tab.id)
|
||||||
|
expect(value.name).to.equal('foo')
|
||||||
|
expect(value.query).to.equal(tab.query)
|
||||||
|
expect(value.viewOptions).to.eql(['chart'])
|
||||||
|
expect(value).to.have.property('createdAt')
|
||||||
|
expect(new Date(value.createdAt)).within(now, nowPlusMinute)
|
||||||
|
expect(new Date(value.updatedAt)).within(now, nowPlusMinute)
|
||||||
|
expect(state.inquiries).to.eql([value])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ describe('mutations', () => {
|
|||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: { here_are: 'chart settings' },
|
viewOptions: { here_are: 'chart settings' },
|
||||||
isSaved: false,
|
isSaved: false,
|
||||||
isPredefined: false
|
isPredefined: false,
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z'
|
||||||
}
|
}
|
||||||
|
|
||||||
const newValues = {
|
const newValues = {
|
||||||
@@ -43,6 +44,7 @@ describe('mutations', () => {
|
|||||||
query: 'SELECT * from bar',
|
query: 'SELECT * from bar',
|
||||||
viewType: 'pivot',
|
viewType: 'pivot',
|
||||||
viewOptions: { here_are: 'pivot settings' },
|
viewOptions: { here_are: 'pivot settings' },
|
||||||
|
updatedAt: '2025-05-15T16:30:00Z',
|
||||||
isSaved: true
|
isSaved: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ describe('mutations', () => {
|
|||||||
query: 'SELECT * from bar',
|
query: 'SELECT * from bar',
|
||||||
viewType: 'pivot',
|
viewType: 'pivot',
|
||||||
viewOptions: { here_are: 'pivot settings' },
|
viewOptions: { here_are: 'pivot settings' },
|
||||||
|
updatedAt: '2025-05-15T16:30:00Z',
|
||||||
isSaved: true
|
isSaved: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import MainMenu from '@/views/MainView/MainMenu'
|
|||||||
import storedInquiries from '@/lib/storedInquiries'
|
import storedInquiries from '@/lib/storedInquiries'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import eventBus from '@/lib/eventBus'
|
import eventBus from '@/lib/eventBus'
|
||||||
|
import actions from '@/store/actions'
|
||||||
|
import mutations from '@/store/mutations'
|
||||||
|
|
||||||
let wrapper = null
|
let wrapper = null
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ describe('MainMenu.vue', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Create and Save are visible only on /workspace page', async () => {
|
it('Create, Save and Save as are visible only on /workspace page', async () => {
|
||||||
const state = {
|
const state = {
|
||||||
currentTab: { query: '', execute: sinon.stub() },
|
currentTab: { query: '', execute: sinon.stub() },
|
||||||
tabs: [{}],
|
tabs: [{}],
|
||||||
@@ -45,6 +47,8 @@ describe('MainMenu.vue', () => {
|
|||||||
})
|
})
|
||||||
expect(wrapper.find('#save-btn').exists()).to.equal(true)
|
expect(wrapper.find('#save-btn').exists()).to.equal(true)
|
||||||
expect(wrapper.find('#save-btn').isVisible()).to.equal(true)
|
expect(wrapper.find('#save-btn').isVisible()).to.equal(true)
|
||||||
|
expect(wrapper.find('#save-as-btn').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('#save-as-btn').isVisible()).to.equal(true)
|
||||||
expect(wrapper.find('#create-btn').exists()).to.equal(true)
|
expect(wrapper.find('#create-btn').exists()).to.equal(true)
|
||||||
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
|
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -65,7 +69,7 @@ describe('MainMenu.vue', () => {
|
|||||||
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
|
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Save is not visible if there is no tabs', () => {
|
it('Save and Save as are not visible if there is no tabs', () => {
|
||||||
const state = {
|
const state = {
|
||||||
currentTab: null,
|
currentTab: null,
|
||||||
tabs: [],
|
tabs: [],
|
||||||
@@ -83,6 +87,8 @@ describe('MainMenu.vue', () => {
|
|||||||
})
|
})
|
||||||
expect(wrapper.find('#save-btn').exists()).to.equal(true)
|
expect(wrapper.find('#save-btn').exists()).to.equal(true)
|
||||||
expect(wrapper.find('#save-btn').isVisible()).to.equal(false)
|
expect(wrapper.find('#save-btn').isVisible()).to.equal(false)
|
||||||
|
expect(wrapper.find('#save-as-btn').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('#save-as-btn').isVisible()).to.equal(false)
|
||||||
expect(wrapper.find('#create-btn').exists()).to.equal(true)
|
expect(wrapper.find('#create-btn').exists()).to.equal(true)
|
||||||
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
|
expect(wrapper.find('#create-btn').isVisible()).to.equal(true)
|
||||||
})
|
})
|
||||||
@@ -111,10 +117,12 @@ describe('MainMenu.vue', () => {
|
|||||||
})
|
})
|
||||||
const vm = wrapper.vm
|
const vm = wrapper.vm
|
||||||
expect(wrapper.find('#save-btn').element.disabled).to.equal(false)
|
expect(wrapper.find('#save-btn').element.disabled).to.equal(false)
|
||||||
|
expect(wrapper.find('#save-as-btn').element.disabled).to.equal(false)
|
||||||
|
|
||||||
store.state.tabs[0].isSaved = true
|
store.state.tabs[0].isSaved = true
|
||||||
await vm.$nextTick()
|
await vm.$nextTick()
|
||||||
expect(wrapper.find('#save-btn').element.disabled).to.equal(true)
|
expect(wrapper.find('#save-btn').element.disabled).to.equal(true)
|
||||||
|
expect(wrapper.find('#save-as-btn').element.disabled).to.equal(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Creates a tab', async () => {
|
it('Creates a tab', async () => {
|
||||||
@@ -332,7 +340,7 @@ describe('MainMenu.vue', () => {
|
|||||||
expect(wrapper.vm.createNewInquiry.callCount).to.equal(4)
|
expect(wrapper.vm.createNewInquiry.callCount).to.equal(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Ctrl S calls checkInquiryBeforeSave if the tab is unsaved and route path is /workspace', async () => {
|
it('Ctrl S calls onSave if the tab is unsaved and route path is /workspace', async () => {
|
||||||
const tab = {
|
const tab = {
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
execute: sinon.stub(),
|
execute: sinon.stub(),
|
||||||
@@ -353,36 +361,34 @@ describe('MainMenu.vue', () => {
|
|||||||
plugins: [store]
|
plugins: [store]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
sinon.stub(wrapper.vm, 'checkInquiryBeforeSave')
|
sinon.stub(wrapper.vm, 'onSave')
|
||||||
|
|
||||||
const ctrlS = new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
const ctrlS = new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||||
const metaS = new KeyboardEvent('keydown', { key: 's', metaKey: true })
|
const metaS = new KeyboardEvent('keydown', { key: 's', metaKey: true })
|
||||||
// tab is unsaved and route is /workspace
|
// tab is unsaved and route is /workspace
|
||||||
document.dispatchEvent(ctrlS)
|
document.dispatchEvent(ctrlS)
|
||||||
expect(wrapper.vm.checkInquiryBeforeSave.calledOnce).to.equal(true)
|
expect(wrapper.vm.onSave.calledOnce).to.equal(true)
|
||||||
document.dispatchEvent(metaS)
|
document.dispatchEvent(metaS)
|
||||||
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
|
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
|
||||||
|
|
||||||
// tab is saved and route is /workspace
|
// tab is saved and route is /workspace
|
||||||
store.state.tabs[0].isSaved = true
|
store.state.tabs[0].isSaved = true
|
||||||
document.dispatchEvent(ctrlS)
|
document.dispatchEvent(ctrlS)
|
||||||
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
|
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
|
||||||
document.dispatchEvent(metaS)
|
document.dispatchEvent(metaS)
|
||||||
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
|
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
|
||||||
|
|
||||||
// tab is unsaved and route is not /workspace
|
// tab is unsaved and route is not /workspace
|
||||||
wrapper.vm.$route.path = '/inquiries'
|
wrapper.vm.$route.path = '/inquiries'
|
||||||
store.state.tabs[0].isSaved = false
|
store.state.tabs[0].isSaved = false
|
||||||
document.dispatchEvent(ctrlS)
|
document.dispatchEvent(ctrlS)
|
||||||
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
|
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
|
||||||
document.dispatchEvent(metaS)
|
document.dispatchEvent(metaS)
|
||||||
expect(wrapper.vm.checkInquiryBeforeSave.calledTwice).to.equal(true)
|
expect(wrapper.vm.onSave.calledTwice).to.equal(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Saves the inquiry when no need the new name', async () => {
|
it('Ctrl Shift S calls onSaveAs if route path is /workspace', async () => {
|
||||||
const tab = {
|
const tab = {
|
||||||
id: 1,
|
|
||||||
name: 'foo',
|
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
execute: sinon.stub(),
|
execute: sinon.stub(),
|
||||||
isSaved: false
|
isSaved: false
|
||||||
@@ -392,6 +398,81 @@ describe('MainMenu.vue', () => {
|
|||||||
tabs: [tab],
|
tabs: [tab],
|
||||||
db: {}
|
db: {}
|
||||||
}
|
}
|
||||||
|
const store = createStore({ state })
|
||||||
|
const $route = { path: '/workspace' }
|
||||||
|
|
||||||
|
wrapper = shallowMount(MainMenu, {
|
||||||
|
global: {
|
||||||
|
mocks: { $route },
|
||||||
|
stubs: ['router-link'],
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sinon.stub(wrapper.vm, 'onSaveAs')
|
||||||
|
|
||||||
|
const ctrlS = new KeyboardEvent('keydown', {
|
||||||
|
key: 'S',
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true
|
||||||
|
})
|
||||||
|
const metaS = new KeyboardEvent('keydown', {
|
||||||
|
key: 'S',
|
||||||
|
metaKey: true,
|
||||||
|
shiftKey: true
|
||||||
|
})
|
||||||
|
// tab is unsaved and route is /workspace
|
||||||
|
document.dispatchEvent(ctrlS)
|
||||||
|
expect(wrapper.vm.onSaveAs.calledOnce).to.equal(true)
|
||||||
|
document.dispatchEvent(metaS)
|
||||||
|
expect(wrapper.vm.onSaveAs.calledTwice).to.equal(true)
|
||||||
|
|
||||||
|
// tab is saved and route is /workspace
|
||||||
|
store.state.tabs[0].isSaved = true
|
||||||
|
document.dispatchEvent(ctrlS)
|
||||||
|
expect(wrapper.vm.onSaveAs.calledThrice).to.equal(true)
|
||||||
|
document.dispatchEvent(metaS)
|
||||||
|
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
|
||||||
|
|
||||||
|
// tab is unsaved and route is not /workspace
|
||||||
|
wrapper.vm.$route.path = '/inquiries'
|
||||||
|
store.state.tabs[0].isSaved = false
|
||||||
|
document.dispatchEvent(ctrlS)
|
||||||
|
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
|
||||||
|
document.dispatchEvent(metaS)
|
||||||
|
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
|
||||||
|
|
||||||
|
// tab is saved and route is not /workspace
|
||||||
|
wrapper.vm.$route.path = '/inquiries'
|
||||||
|
store.state.tabs[0].isSaved = true
|
||||||
|
document.dispatchEvent(ctrlS)
|
||||||
|
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
|
||||||
|
document.dispatchEvent(metaS)
|
||||||
|
expect(wrapper.vm.onSaveAs.callCount).to.equal(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Saves the inquiry when no need the new name and no update conflict', async () => {
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z',
|
||||||
|
execute: sinon.stub(),
|
||||||
|
isSaved: false
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
currentTab: tab,
|
||||||
|
inquiries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z',
|
||||||
|
createdAt: '2025-05-14T15:30:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tabs: [tab],
|
||||||
|
db: {}
|
||||||
|
}
|
||||||
const mutations = {
|
const mutations = {
|
||||||
updateTab: sinon.stub()
|
updateTab: sinon.stub()
|
||||||
}
|
}
|
||||||
@@ -401,7 +482,8 @@ describe('MainMenu.vue', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: []
|
viewOptions: [],
|
||||||
|
updatedAt: '2025-05-16T15:30:00Z'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const store = createStore({ state, mutations, actions })
|
const store = createStore({ state, mutations, actions })
|
||||||
@@ -446,7 +528,8 @@ describe('MainMenu.vue', () => {
|
|||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: [],
|
viewOptions: [],
|
||||||
isSaved: true
|
isSaved: true,
|
||||||
|
updatedAt: '2025-05-16T15:30:00Z'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -456,6 +539,396 @@ describe('MainMenu.vue', () => {
|
|||||||
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
|
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Inquiry conflict: overwrite', async () => {
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z',
|
||||||
|
execute: sinon.stub(),
|
||||||
|
isSaved: false
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
currentTab: tab,
|
||||||
|
inquiries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM bar',
|
||||||
|
updatedAt: '2025-05-15T16:30:00Z',
|
||||||
|
createdAt: '2025-05-14T15:30:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tabs: [tab],
|
||||||
|
db: {}
|
||||||
|
}
|
||||||
|
const mutations = {
|
||||||
|
updateTab: sinon.stub()
|
||||||
|
}
|
||||||
|
const actions = {
|
||||||
|
saveInquiry: sinon.stub().returns({
|
||||||
|
name: 'foo',
|
||||||
|
id: 1,
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
updatedAt: '2025-05-16T17:30:00Z',
|
||||||
|
createdAt: '2025-05-14T15:30:00Z'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const store = createStore({ state, mutations, actions })
|
||||||
|
const $route = { path: '/workspace' }
|
||||||
|
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
|
||||||
|
|
||||||
|
wrapper = mount(MainMenu, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
mocks: { $route },
|
||||||
|
stubs: {
|
||||||
|
'router-link': true,
|
||||||
|
'app-diagnostic-info': true,
|
||||||
|
teleport: true,
|
||||||
|
transition: false
|
||||||
|
},
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('#save-btn').trigger('click')
|
||||||
|
|
||||||
|
// check that the conflict dialog is open
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Inquiry saving conflict'
|
||||||
|
)
|
||||||
|
|
||||||
|
// find Overwrite in the dialog and click
|
||||||
|
await wrapper
|
||||||
|
.findAll('.dialog-buttons-container button')
|
||||||
|
.find(button => button.text() === 'Overwrite')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// check that the dialog is closed
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// check that the inquiry was saved via saveInquiry (newName='')
|
||||||
|
expect(actions.saveInquiry.calledOnce).to.equal(true)
|
||||||
|
expect(actions.saveInquiry.args[0][1]).to.eql({
|
||||||
|
inquiryTab: state.currentTab,
|
||||||
|
newName: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// check that the tab was updated
|
||||||
|
expect(
|
||||||
|
mutations.updateTab.calledOnceWith(
|
||||||
|
state,
|
||||||
|
sinon.match({
|
||||||
|
tab,
|
||||||
|
newValues: {
|
||||||
|
name: 'foo',
|
||||||
|
id: 1,
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
isSaved: true,
|
||||||
|
updatedAt: '2025-05-16T17:30:00Z'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).to.equal(true)
|
||||||
|
|
||||||
|
// check that 'inquirySaved' event was triggered on eventBus
|
||||||
|
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Inquiry conflict after saving new inquiry: overwrite', async () => {
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
name: null,
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
updatedAt: undefined,
|
||||||
|
execute: sinon.stub(),
|
||||||
|
dataView: { getOptionsForSave: sinon.stub() },
|
||||||
|
isSaved: false
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
currentTab: tab,
|
||||||
|
inquiries: [],
|
||||||
|
tabs: [tab],
|
||||||
|
db: {}
|
||||||
|
}
|
||||||
|
const store = createStore({ state, mutations, actions })
|
||||||
|
const $route = { path: '/workspace' }
|
||||||
|
|
||||||
|
wrapper = mount(MainMenu, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
mocks: { $route },
|
||||||
|
stubs: {
|
||||||
|
'router-link': true,
|
||||||
|
'app-diagnostic-info': true,
|
||||||
|
teleport: true,
|
||||||
|
transition: false
|
||||||
|
},
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('#save-btn').trigger('click')
|
||||||
|
|
||||||
|
// check that Save dialog is open
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Save inquiry'
|
||||||
|
)
|
||||||
|
|
||||||
|
// enter the name
|
||||||
|
await wrapper.find('.dialog-body input').setValue('foo')
|
||||||
|
|
||||||
|
// find Save in the dialog and click
|
||||||
|
await wrapper
|
||||||
|
.findAll('.dialog-buttons-container button')
|
||||||
|
.find(button => button.text() === 'Save')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// check that the dialog is closed
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// check that now there is one inquiry saved
|
||||||
|
expect(state.inquiries.length).to.equal(1)
|
||||||
|
expect(state.inquiries[0].name).to.equal('foo')
|
||||||
|
expect(state.tabs[0].name).to.equal('foo')
|
||||||
|
|
||||||
|
// change the inquiry in store (like it's updated in another tab)
|
||||||
|
store.state.inquiries[0].query = 'SELECT * FROM foo_updated_in_another_tab'
|
||||||
|
store.state.inquiries[0].updatedAt = '2025-05-15T00:00:10Z'
|
||||||
|
store.state.currentTab.query = 'SELECT * FROM foo_new'
|
||||||
|
store.state.currentTab.isSaved = false
|
||||||
|
await nextTick()
|
||||||
|
await wrapper.find('#save-btn').trigger('click')
|
||||||
|
|
||||||
|
// check that the conflict dialog is open
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Inquiry saving conflict'
|
||||||
|
)
|
||||||
|
|
||||||
|
// find Overwrite in the dialog and click
|
||||||
|
await wrapper
|
||||||
|
.findAll('.dialog-buttons-container button')
|
||||||
|
.find(button => button.text() === 'Overwrite')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// check that the dialog is closed
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// check that it's still one inquiry saved
|
||||||
|
expect(state.inquiries.length).to.equal(1)
|
||||||
|
expect(state.inquiries[0].name).to.equal('foo')
|
||||||
|
expect(state.tabs[0].name).to.equal('foo')
|
||||||
|
expect(state.inquiries[0].query).to.equal('SELECT * FROM foo_new')
|
||||||
|
expect(state.tabs[0].query).to.equal('SELECT * FROM foo_new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Inquiry conflict: save as new', async () => {
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z',
|
||||||
|
execute: sinon.stub(),
|
||||||
|
isSaved: false
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
currentTab: tab,
|
||||||
|
inquiries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM bar',
|
||||||
|
updatedAt: '2025-05-15T16:30:00Z',
|
||||||
|
createdAt: '2025-05-14T15:30:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tabs: [tab]
|
||||||
|
}
|
||||||
|
const mutations = {
|
||||||
|
updateTab: sinon.stub()
|
||||||
|
}
|
||||||
|
const actions = {
|
||||||
|
saveInquiry: sinon.stub().returns({
|
||||||
|
name: 'foo_new',
|
||||||
|
id: 2,
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
updatedAt: '2025-05-16T17:30:00Z',
|
||||||
|
createdAt: '2025-05-16T17:30:00Z'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const store = createStore({ state, mutations, actions })
|
||||||
|
const $route = { path: '/workspace' }
|
||||||
|
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
|
||||||
|
|
||||||
|
wrapper = mount(MainMenu, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
mocks: { $route },
|
||||||
|
stubs: {
|
||||||
|
'router-link': true,
|
||||||
|
'app-diagnostic-info': true,
|
||||||
|
teleport: true,
|
||||||
|
transition: false
|
||||||
|
},
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('#save-btn').trigger('click')
|
||||||
|
// check that the conflict dialog is open
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Inquiry saving conflict'
|
||||||
|
)
|
||||||
|
|
||||||
|
await clock.tick(100)
|
||||||
|
|
||||||
|
// find "Save as new" in the dialog and click
|
||||||
|
|
||||||
|
await wrapper
|
||||||
|
.findAll('.dialog-buttons-container button')
|
||||||
|
.find(button => button.text() === 'Save as new')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// Hiding any dialog is done with tiny animation. Give time to finish it:
|
||||||
|
await clock.tick(100)
|
||||||
|
// Note: don't call nextTick before clock.tick. That leads to extra trap in
|
||||||
|
// trapStack and the test fails with focus-trap error in afterEach hook
|
||||||
|
// when unmount the component
|
||||||
|
|
||||||
|
// check that only one dialog open
|
||||||
|
expect(wrapper.findAll('.dialog.vfm').length).to.equal(1)
|
||||||
|
// enter the new name
|
||||||
|
await wrapper.find('.dialog-body input').setValue('foo_new')
|
||||||
|
|
||||||
|
// find Save in the dialog and click
|
||||||
|
await wrapper
|
||||||
|
.findAll('.dialog-buttons-container button')
|
||||||
|
.find(button => button.text() === 'Save')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// check that the dialog is closed
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// check that the inquiry was saved via saveInquiry (newName='foo_new')
|
||||||
|
expect(actions.saveInquiry.calledOnce).to.equal(true)
|
||||||
|
expect(actions.saveInquiry.args[0][1]).to.eql({
|
||||||
|
inquiryTab: state.currentTab,
|
||||||
|
newName: 'foo_new'
|
||||||
|
})
|
||||||
|
|
||||||
|
// check that the tab was updated
|
||||||
|
expect(
|
||||||
|
mutations.updateTab.calledOnceWith(
|
||||||
|
state,
|
||||||
|
sinon.match({
|
||||||
|
tab,
|
||||||
|
newValues: {
|
||||||
|
name: 'foo_new',
|
||||||
|
id: 2,
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
isSaved: true,
|
||||||
|
updatedAt: '2025-05-16T17:30:00Z'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).to.equal(true)
|
||||||
|
|
||||||
|
// check that 'inquirySaved' event was triggered on eventBus
|
||||||
|
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Inquiry conflict: cancel', async () => {
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z',
|
||||||
|
execute: sinon.stub(),
|
||||||
|
isSaved: false
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
currentTab: tab,
|
||||||
|
inquiries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM bar',
|
||||||
|
updatedAt: '2025-05-15T16:30:00Z',
|
||||||
|
createdAt: '2025-05-14T15:30:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tabs: [tab],
|
||||||
|
db: {}
|
||||||
|
}
|
||||||
|
const mutations = {
|
||||||
|
updateTab: sinon.stub()
|
||||||
|
}
|
||||||
|
const actions = {
|
||||||
|
saveInquiry: sinon.stub()
|
||||||
|
}
|
||||||
|
const store = createStore({ state, mutations, actions })
|
||||||
|
const $route = { path: '/workspace' }
|
||||||
|
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
|
||||||
|
|
||||||
|
wrapper = mount(MainMenu, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
mocks: { $route },
|
||||||
|
stubs: {
|
||||||
|
'router-link': true,
|
||||||
|
'app-diagnostic-info': true,
|
||||||
|
teleport: true,
|
||||||
|
transition: false
|
||||||
|
},
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('#save-btn').trigger('click')
|
||||||
|
|
||||||
|
// check that the conflict dialog is open
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Inquiry saving conflict'
|
||||||
|
)
|
||||||
|
|
||||||
|
// find Cancel in the dialog and click
|
||||||
|
await wrapper
|
||||||
|
.findAll('.dialog-buttons-container button')
|
||||||
|
.find(button => button.text() === 'Cancel')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// check that the dialog is closed
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// check that the inquiry was not saved via storedInquiries.save
|
||||||
|
expect(actions.saveInquiry.called).to.equal(false)
|
||||||
|
|
||||||
|
// check that the tab was not updated
|
||||||
|
expect(mutations.updateTab.called).to.equal(false)
|
||||||
|
|
||||||
|
// check that 'inquirySaved' event is not listened on eventBus
|
||||||
|
expect(eventBus.$off.calledOnceWith('inquirySaved')).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('Shows en error when the new name is needed but not specifyied', async () => {
|
it('Shows en error when the new name is needed but not specifyied', async () => {
|
||||||
const tab = {
|
const tab = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -463,7 +936,8 @@ describe('MainMenu.vue', () => {
|
|||||||
tempName: 'Untitled',
|
tempName: 'Untitled',
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
execute: sinon.stub(),
|
execute: sinon.stub(),
|
||||||
isSaved: false
|
isSaved: false,
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z'
|
||||||
}
|
}
|
||||||
const state = {
|
const state = {
|
||||||
currentTab: tab,
|
currentTab: tab,
|
||||||
@@ -479,7 +953,8 @@ describe('MainMenu.vue', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: []
|
viewOptions: [],
|
||||||
|
updatedAt: '2025-05-16T15:30:00Z'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const store = createStore({ state, mutations, actions })
|
const store = createStore({ state, mutations, actions })
|
||||||
@@ -522,14 +997,15 @@ describe('MainMenu.vue', () => {
|
|||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Saves the inquiry with a new name', async () => {
|
it('Saves the new inquiry with a new name', async () => {
|
||||||
const tab = {
|
const tab = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: null,
|
name: null,
|
||||||
tempName: 'Untitled',
|
tempName: 'Untitled',
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
execute: sinon.stub(),
|
execute: sinon.stub(),
|
||||||
isSaved: false
|
isSaved: false,
|
||||||
|
updatedAt: undefined
|
||||||
}
|
}
|
||||||
const state = {
|
const state = {
|
||||||
currentTab: tab,
|
currentTab: tab,
|
||||||
@@ -542,10 +1018,11 @@ describe('MainMenu.vue', () => {
|
|||||||
const actions = {
|
const actions = {
|
||||||
saveInquiry: sinon.stub().returns({
|
saveInquiry: sinon.stub().returns({
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
id: 1,
|
id: 2,
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: []
|
viewOptions: [],
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const store = createStore({ state, mutations, actions })
|
const store = createStore({ state, mutations, actions })
|
||||||
@@ -583,8 +1060,6 @@ describe('MainMenu.vue', () => {
|
|||||||
.find(button => button.text() === 'Save')
|
.find(button => button.text() === 'Save')
|
||||||
.trigger('click')
|
.trigger('click')
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// check that the dialog is closed
|
// check that the dialog is closed
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
@@ -604,11 +1079,12 @@ describe('MainMenu.vue', () => {
|
|||||||
tab,
|
tab,
|
||||||
newValues: {
|
newValues: {
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
id: 1,
|
id: 2,
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: [],
|
viewOptions: [],
|
||||||
isSaved: true
|
isSaved: true,
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -650,7 +1126,8 @@ describe('MainMenu.vue', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: []
|
viewOptions: [],
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const store = createStore({ state, mutations, actions })
|
const store = createStore({ state, mutations, actions })
|
||||||
@@ -691,8 +1168,6 @@ describe('MainMenu.vue', () => {
|
|||||||
.find(button => button.text() === 'Save')
|
.find(button => button.text() === 'Save')
|
||||||
.trigger('click')
|
.trigger('click')
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// check that the dialog is closed
|
// check that the dialog is closed
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
@@ -716,7 +1191,8 @@ describe('MainMenu.vue', () => {
|
|||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
viewType: 'chart',
|
viewType: 'chart',
|
||||||
viewOptions: [],
|
viewOptions: [],
|
||||||
isSaved: true
|
isSaved: true,
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -724,19 +1200,6 @@ describe('MainMenu.vue', () => {
|
|||||||
|
|
||||||
// check that 'inquirySaved' event was triggered on eventBus
|
// check that 'inquirySaved' event was triggered on eventBus
|
||||||
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
|
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 () => {
|
it('Cancel saving', async () => {
|
||||||
@@ -761,7 +1224,7 @@ describe('MainMenu.vue', () => {
|
|||||||
name: 'bar',
|
name: 'bar',
|
||||||
id: 2,
|
id: 2,
|
||||||
query: 'SELECT * FROM foo',
|
query: 'SELECT * FROM foo',
|
||||||
chart: []
|
viewType: 'chart'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const store = createStore({ state, mutations, actions })
|
const store = createStore({ state, mutations, actions })
|
||||||
@@ -809,4 +1272,110 @@ describe('MainMenu.vue', () => {
|
|||||||
// check that 'inquirySaved' event is not listened on eventBus
|
// check that 'inquirySaved' event is not listened on eventBus
|
||||||
expect(eventBus.$off.calledOnceWith('inquirySaved')).to.equal(true)
|
expect(eventBus.$off.calledOnceWith('inquirySaved')).to.equal(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Save the inquiry as new', async () => {
|
||||||
|
const tab = {
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
updatedAt: '2025-05-15T15:30:00Z',
|
||||||
|
execute: sinon.stub(),
|
||||||
|
isSaved: true
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
currentTab: tab,
|
||||||
|
inquiries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'SELECT * FROM bar',
|
||||||
|
updatedAt: '2025-05-15T16:30:00Z',
|
||||||
|
createdAt: '2025-05-14T15:30:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tabs: [tab],
|
||||||
|
db: {}
|
||||||
|
}
|
||||||
|
const mutations = {
|
||||||
|
updateTab: sinon.stub()
|
||||||
|
}
|
||||||
|
const actions = {
|
||||||
|
saveInquiry: sinon.stub().returns({
|
||||||
|
name: 'foo_new',
|
||||||
|
id: 2,
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
updatedAt: '2025-05-16T17:30:00Z',
|
||||||
|
createdAt: '2025-05-16T17:30:00Z'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const store = createStore({ state, mutations, actions })
|
||||||
|
const $route = { path: '/workspace' }
|
||||||
|
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
|
||||||
|
|
||||||
|
wrapper = mount(MainMenu, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
mocks: { $route },
|
||||||
|
stubs: {
|
||||||
|
'router-link': true,
|
||||||
|
'app-diagnostic-info': true,
|
||||||
|
teleport: true,
|
||||||
|
transition: false
|
||||||
|
},
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('#save-as-btn').trigger('click')
|
||||||
|
|
||||||
|
// check that Save dialog is open
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Save inquiry'
|
||||||
|
)
|
||||||
|
|
||||||
|
// enter the new name
|
||||||
|
await wrapper.find('.dialog-body input').setValue('foo_new')
|
||||||
|
|
||||||
|
// find Save in the dialog and click
|
||||||
|
await wrapper
|
||||||
|
.findAll('.dialog-buttons-container button')
|
||||||
|
.find(button => button.text() === 'Save')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// check that the dialog is closed
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// check that the inquiry was saved via saveInquiry (newName='foo_new')
|
||||||
|
expect(actions.saveInquiry.calledOnce).to.equal(true)
|
||||||
|
expect(actions.saveInquiry.args[0][1]).to.eql({
|
||||||
|
inquiryTab: state.currentTab,
|
||||||
|
newName: 'foo_new'
|
||||||
|
})
|
||||||
|
|
||||||
|
// check that the tab was updated
|
||||||
|
expect(
|
||||||
|
mutations.updateTab.calledOnceWith(
|
||||||
|
state,
|
||||||
|
sinon.match({
|
||||||
|
tab,
|
||||||
|
newValues: {
|
||||||
|
name: 'foo_new',
|
||||||
|
id: 2,
|
||||||
|
query: 'SELECT * FROM foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: [],
|
||||||
|
isSaved: true,
|
||||||
|
updatedAt: '2025-05-16T17:30:00Z'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).to.equal(true)
|
||||||
|
|
||||||
|
// check that 'inquirySaved' event was triggered on eventBus
|
||||||
|
expect(eventBus.$emit.calledOnceWith('inquirySaved')).to.equal(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'
|
|||||||
import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView'
|
import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView'
|
||||||
import sinon from 'sinon'
|
import sinon from 'sinon'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
|
import cIo from '@/lib/utils/clipboardIo'
|
||||||
|
|
||||||
describe('DataView.vue', () => {
|
describe('DataView.vue', () => {
|
||||||
const $store = { state: { isWorkspaceVisible: true } }
|
const $store = { state: { isWorkspaceVisible: true } }
|
||||||
@@ -64,7 +65,7 @@ describe('DataView.vue', () => {
|
|||||||
|
|
||||||
// Find chart and spy the method
|
// Find chart and spy the method
|
||||||
const chart = wrapper.findComponent({ name: 'Chart' }).vm
|
const chart = wrapper.findComponent({ name: 'Chart' }).vm
|
||||||
sinon.spy(chart, 'saveAsSvg')
|
sinon.stub(chart, 'saveAsSvg')
|
||||||
|
|
||||||
// Export to svg
|
// Export to svg
|
||||||
const svgBtn = wrapper.findComponent({ ref: 'svgExportBtn' })
|
const svgBtn = wrapper.findComponent({ ref: 'svgExportBtn' })
|
||||||
@@ -77,7 +78,7 @@ describe('DataView.vue', () => {
|
|||||||
|
|
||||||
// Find pivot and spy the method
|
// Find pivot and spy the method
|
||||||
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
|
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
|
||||||
sinon.spy(pivot, 'saveAsSvg')
|
sinon.stub(pivot, 'saveAsSvg')
|
||||||
|
|
||||||
// Switch to Custom Chart renderer
|
// Switch to Custom Chart renderer
|
||||||
pivot.pivotOptions.rendererName = 'Custom chart'
|
pivot.pivotOptions.rendererName = 'Custom chart'
|
||||||
@@ -146,6 +147,7 @@ describe('DataView.vue', () => {
|
|||||||
|
|
||||||
it('copy to clipboard more than 1 sec', async () => {
|
it('copy to clipboard more than 1 sec', async () => {
|
||||||
sinon.stub(window.navigator.clipboard, 'write').resolves()
|
sinon.stub(window.navigator.clipboard, 'write').resolves()
|
||||||
|
sinon.stub(cIo, 'copyImage')
|
||||||
const clock = sinon.useFakeTimers()
|
const clock = sinon.useFakeTimers()
|
||||||
const wrapper = mount(DataView, {
|
const wrapper = mount(DataView, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
@@ -165,7 +167,7 @@ describe('DataView.vue', () => {
|
|||||||
await copyBtn.trigger('click')
|
await copyBtn.trigger('click')
|
||||||
|
|
||||||
// The dialog is shown...
|
// The dialog is shown...
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
|
||||||
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
'Copy to clipboard'
|
'Copy to clipboard'
|
||||||
)
|
)
|
||||||
@@ -180,11 +182,10 @@ describe('DataView.vue', () => {
|
|||||||
// Wait untill prepareCopy is finished
|
// Wait untill prepareCopy is finished
|
||||||
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
|
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// The dialog is shown...
|
// The dialog is shown...
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
|
||||||
|
|
||||||
// ... with Ready message...
|
// ... with Ready message...
|
||||||
expect(wrapper.find('.dialog-body').text()).to.equal('Image is ready')
|
expect(wrapper.find('.dialog-body').text()).to.equal('Image is ready')
|
||||||
@@ -196,12 +197,13 @@ describe('DataView.vue', () => {
|
|||||||
|
|
||||||
// The dialog is not shown...
|
// The dialog is not shown...
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copy to clipboard less than 1 sec', async () => {
|
it('copy to clipboard less than 1 sec', async () => {
|
||||||
sinon.stub(window.navigator.clipboard, 'write').resolves()
|
sinon.stub(window.navigator.clipboard, 'write').resolves()
|
||||||
|
sinon.stub(cIo, 'copyImage')
|
||||||
const clock = sinon.useFakeTimers()
|
const clock = sinon.useFakeTimers()
|
||||||
const wrapper = mount(DataView, {
|
const wrapper = mount(DataView, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
@@ -226,10 +228,9 @@ describe('DataView.vue', () => {
|
|||||||
// Wait untill prepareCopy is finished
|
// Wait untill prepareCopy is finished
|
||||||
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
|
await wrapper.vm.$refs.viewComponent.prepareCopy.returnValues[0]
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
// The dialog is not shown...
|
// The dialog is not shown...
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
// copyToClipboard is called
|
// copyToClipboard is called
|
||||||
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
|
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -270,7 +271,7 @@ describe('DataView.vue', () => {
|
|||||||
|
|
||||||
// The dialog is not shown...
|
// The dialog is not shown...
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
// copyToClipboard is not called
|
// copyToClipboard is not called
|
||||||
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
|
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe('RunResult.vue', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// The dialog is shown...
|
// The dialog is shown...
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
|
||||||
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
'Copy to clipboard'
|
'Copy to clipboard'
|
||||||
)
|
)
|
||||||
@@ -91,7 +91,7 @@ describe('RunResult.vue', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// The dialog is shown...
|
// The dialog is shown...
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(true)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
|
||||||
|
|
||||||
// ... with Ready message...
|
// ... with Ready message...
|
||||||
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
|
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
|
||||||
@@ -104,7 +104,7 @@ describe('RunResult.vue', () => {
|
|||||||
|
|
||||||
// The dialog is not shown...
|
// The dialog is not shown...
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -139,11 +139,10 @@ describe('RunResult.vue', () => {
|
|||||||
|
|
||||||
// Switch to microtasks (let serialize run)
|
// Switch to microtasks (let serialize run)
|
||||||
await clock.tick(0)
|
await clock.tick(0)
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// The dialog is not shown...
|
// The dialog is not shown...
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
// copyToClipboard is called
|
// copyToClipboard is called
|
||||||
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
|
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -188,7 +187,7 @@ describe('RunResult.vue', () => {
|
|||||||
.trigger('click')
|
.trigger('click')
|
||||||
// The dialog is not shown...
|
// The dialog is not shown...
|
||||||
await clock.tick(100)
|
await clock.tick(100)
|
||||||
expect(wrapper.find('.dialog.vfm').exists()).to.equal(false)
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
// copyToClipboard is not called
|
// copyToClipboard is not called
|
||||||
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
|
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import mutations from '@/store/mutations'
|
|||||||
import { createStore } from 'vuex'
|
import { createStore } from 'vuex'
|
||||||
import Tabs from '@/views/MainView/Workspace/Tabs'
|
import Tabs from '@/views/MainView/Workspace/Tabs'
|
||||||
import eventBus from '@/lib/eventBus'
|
import eventBus from '@/lib/eventBus'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import cIo from '@/lib/utils/clipboardIo'
|
||||||
|
import csv from '@/lib/csv'
|
||||||
|
|
||||||
describe('Tabs.vue', () => {
|
describe('Tabs.vue', () => {
|
||||||
let clock
|
let clock
|
||||||
@@ -46,7 +49,7 @@ describe('Tabs.vue', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
query: 'select * from foo',
|
query: 'select * from foo',
|
||||||
chart: [],
|
viewType: 'chart',
|
||||||
isSaved: true
|
isSaved: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -54,7 +57,7 @@ describe('Tabs.vue', () => {
|
|||||||
name: null,
|
name: null,
|
||||||
tempName: 'Untitled',
|
tempName: 'Untitled',
|
||||||
query: '',
|
query: '',
|
||||||
chart: [],
|
viewType: 'chart',
|
||||||
isSaved: false
|
isSaved: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -97,7 +100,7 @@ describe('Tabs.vue', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
query: 'select * from foo',
|
query: 'select * from foo',
|
||||||
chart: [],
|
viewType: 'chart',
|
||||||
isSaved: true
|
isSaved: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,7 +108,7 @@ describe('Tabs.vue', () => {
|
|||||||
name: null,
|
name: null,
|
||||||
tempName: 'Untitled',
|
tempName: 'Untitled',
|
||||||
query: '',
|
query: '',
|
||||||
chart: [],
|
viewType: 'chart',
|
||||||
isSaved: false
|
isSaved: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -436,7 +439,7 @@ describe('Tabs.vue', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
query: 'select * from foo',
|
query: 'select * from foo',
|
||||||
chart: [],
|
viewType: 'chart',
|
||||||
isSaved: true
|
isSaved: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -444,7 +447,7 @@ describe('Tabs.vue', () => {
|
|||||||
name: null,
|
name: null,
|
||||||
tempName: 'Untitled',
|
tempName: 'Untitled',
|
||||||
query: '',
|
query: '',
|
||||||
chart: [],
|
viewType: 'chart',
|
||||||
isSaved: false
|
isSaved: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -477,7 +480,7 @@ describe('Tabs.vue', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
query: 'select * from foo',
|
query: 'select * from foo',
|
||||||
chart: [],
|
viewType: 'chart',
|
||||||
isSaved: true
|
isSaved: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -501,4 +504,216 @@ describe('Tabs.vue', () => {
|
|||||||
expect(event.preventDefault.calledOnce).to.equal(false)
|
expect(event.preventDefault.calledOnce).to.equal(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Copy image to clipboard dialog works in the context of the tab', async () => {
|
||||||
|
// mock store state - 2 inquiries open
|
||||||
|
const state = {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'select * from foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: undefined,
|
||||||
|
layout: {
|
||||||
|
sqlEditor: 'above',
|
||||||
|
table: 'hidden',
|
||||||
|
dataView: 'bottom'
|
||||||
|
},
|
||||||
|
isSaved: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: null,
|
||||||
|
tempName: 'Untitled',
|
||||||
|
query: '',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: undefined,
|
||||||
|
layout: {
|
||||||
|
sqlEditor: 'above',
|
||||||
|
table: 'hidden',
|
||||||
|
dataView: 'bottom'
|
||||||
|
},
|
||||||
|
isSaved: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
currentTabId: 2
|
||||||
|
}
|
||||||
|
const store = createStore({ state })
|
||||||
|
sinon.stub(cIo, 'copyImage')
|
||||||
|
|
||||||
|
// mount the component
|
||||||
|
const wrapper = mount(Tabs, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
stubs: { teleport: true, transition: true, RouterLink: true },
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const firstTabDataView = wrapper
|
||||||
|
.findAllComponents({ name: 'Tab' })[0]
|
||||||
|
.findComponent({ name: 'DataView' })
|
||||||
|
|
||||||
|
const secondTabDataView = wrapper
|
||||||
|
.findAllComponents({ name: 'Tab' })[1]
|
||||||
|
.findComponent({ name: 'DataView' })
|
||||||
|
|
||||||
|
// Stub prepareCopy method so it takes long and copy dialog will be shown
|
||||||
|
sinon
|
||||||
|
.stub(firstTabDataView.vm.$refs.viewComponent, 'prepareCopy')
|
||||||
|
.callsFake(async () => {
|
||||||
|
await clock.tick(5000)
|
||||||
|
return 'prepareCopy result in tab 1'
|
||||||
|
})
|
||||||
|
|
||||||
|
sinon
|
||||||
|
.stub(secondTabDataView.vm.$refs.viewComponent, 'prepareCopy')
|
||||||
|
.callsFake(async () => {
|
||||||
|
await clock.tick(5000)
|
||||||
|
return 'prepareCopy result in tab 2'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click Copy to clipboard button in the second tab
|
||||||
|
const copyBtn = secondTabDataView.findComponent({
|
||||||
|
ref: 'copyToClipboardBtn'
|
||||||
|
})
|
||||||
|
await copyBtn.trigger('click')
|
||||||
|
|
||||||
|
// The dialog is shown...
|
||||||
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Copy to clipboard'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switch to microtasks (let prepareCopy run)
|
||||||
|
await clock.tick(0)
|
||||||
|
// Wait untill prepareCopy is finished
|
||||||
|
await secondTabDataView.vm.$refs.viewComponent.prepareCopy.returnValues[0]
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Click copy button in the dialog
|
||||||
|
await wrapper
|
||||||
|
.find('.dialog-buttons-container button.primary')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// The dialog is not shown...
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// copyImage is called with prepare copy result calculated in tab 2, not null
|
||||||
|
// i.e. the dialog works in the tab 2 context
|
||||||
|
expect(
|
||||||
|
cIo.copyImage.calledOnceWith('prepareCopy result in tab 2')
|
||||||
|
).to.equal(true)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Copy CSV to clipboard dialog works in the context of the tab', async () => {
|
||||||
|
// mock store state - 2 inquiries open
|
||||||
|
const state = {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
query: 'select * from foo',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: undefined,
|
||||||
|
layout: {
|
||||||
|
sqlEditor: 'above',
|
||||||
|
table: 'bottom',
|
||||||
|
dataView: 'hidden'
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
columns: ['id', 'name'],
|
||||||
|
values: {
|
||||||
|
id: [1, 2, 3],
|
||||||
|
name: ['Gryffindor', 'Hufflepuff']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isSaved: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: null,
|
||||||
|
tempName: 'Untitled',
|
||||||
|
query: '',
|
||||||
|
viewType: 'chart',
|
||||||
|
viewOptions: undefined,
|
||||||
|
layout: {
|
||||||
|
sqlEditor: 'above',
|
||||||
|
table: 'bottom',
|
||||||
|
dataView: 'hidden'
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
columns: ['name', 'points'],
|
||||||
|
values: {
|
||||||
|
name: ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'],
|
||||||
|
points: [100, 90, 95, 80]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isSaved: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
currentTabId: 2
|
||||||
|
}
|
||||||
|
const store = createStore({ state })
|
||||||
|
sinon.stub(cIo, 'copyText')
|
||||||
|
|
||||||
|
// mount the component
|
||||||
|
const wrapper = mount(Tabs, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
stubs: { teleport: true, transition: true, RouterLink: true },
|
||||||
|
plugins: [store]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondTabRunResult = wrapper
|
||||||
|
.findAllComponents({ name: 'Tab' })[1]
|
||||||
|
.findComponent({ name: 'RunResult' })
|
||||||
|
|
||||||
|
// Stub prepareCopy method so it takes long and copy dialog will be shown
|
||||||
|
sinon.stub(csv, 'serialize').callsFake(() => {
|
||||||
|
clock.tick(5000)
|
||||||
|
return 'csv serialize result'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click Copy to clipboard button in the second tab
|
||||||
|
const copyBtn = secondTabRunResult.findComponent({
|
||||||
|
ref: 'copyToClipboardBtn'
|
||||||
|
})
|
||||||
|
await copyBtn.trigger('click')
|
||||||
|
|
||||||
|
// The dialog is shown...
|
||||||
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(true)
|
||||||
|
expect(wrapper.find('.dialog.vfm .dialog-header').text()).to.contain(
|
||||||
|
'Copy to clipboard'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switch to microtasks (let prepareCopy run)
|
||||||
|
await clock.tick(0)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Click copy button in the dialog
|
||||||
|
await wrapper
|
||||||
|
.find('.dialog-buttons-container button.primary')
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
// The dialog is not shown...
|
||||||
|
await clock.tick(100)
|
||||||
|
expect(wrapper.find('.dialog.vfm .vfm__content').exists()).to.equal(false)
|
||||||
|
|
||||||
|
// copyText is called with 'csv serialize result' calculated in tab 2, not null
|
||||||
|
// i.e. the dialog works in the tab 2 context
|
||||||
|
expect(
|
||||||
|
cIo.copyText.calledOnceWith(
|
||||||
|
'csv serialize result',
|
||||||
|
'CSV copied to clipboard successfully'
|
||||||
|
)
|
||||||
|
).to.equal(true)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user