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

Compare commits

..

12 Commits

Author SHA1 Message Date
lana-k
c2c376219f fix lint 2026-02-28 17:59:39 +01:00
lana-k
0199415dde 0.29.0 2026-02-28 17:44:17 +01:00
lana-k
534b186d76 tests for graph editor 2026-02-28 16:53:43 +01:00
lana-k
4f6efb5bda test for DataView 2026-02-26 22:32:24 +01:00
lana-k
2c0b8f9124 enable webgl in chromium 2026-02-25 22:58:28 +01:00
lana-k
5265f5493e splitpanes test 2026-02-24 21:28:11 +01:00
lana-k
c0e59f6fb8 update run result test 2026-02-24 21:02:06 +01:00
lana-k
7471744633 add tests for Value Viewer 2026-02-24 20:53:03 +01:00
lana-k
e6e5efa8c6 tests for graphHelper 2026-02-22 22:30:18 +01:00
lana-k
57c36b3900 tests for migration 2026-02-07 22:08:39 +01:00
lana-k
1e8c1761e6 #133 highlight nodes and edges 2026-02-07 21:18:49 +01:00
lana-k
dd30e17ff5 #133 Add node data viewer 2026-01-25 22:15:48 +01:00
20 changed files with 2249 additions and 213 deletions

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@
:initOptions="initOptionsByMode[mode]"
:data-sources="dataSource"
:showViewSettings="showViewSettings"
:showValueViewer="viewValuePanelVisible"
@loading-image-completed="loadingImage = false"
@update="$emit('update')"
/>
@@ -56,6 +57,17 @@
<settings-icon />
</icon-button>
<icon-button
v-if="mode === 'graph'"
ref="viewNodeOrEdgeBtn"
tooltip="View node or edge details"
tooltipPosition="top-left"
:active="viewValuePanelVisible"
@click="viewValuePanelVisible = !viewValuePanelVisible"
>
<view-cell-value-icon />
</icon-button>
<div class="side-tool-bar-divider" />
<icon-button
@@ -126,6 +138,7 @@ import HtmlIcon from '@/components/svg/html'
import ExportToSvgIcon from '@/components/svg/exportToSvg'
import PngIcon from '@/components/svg/png'
import ClipboardIcon from '@/components/svg/clipboard'
import ViewCellValueIcon from '@/components/svg/viewCellValue'
import cIo from '@/lib/utils/clipboardIo'
import loadingDialog from '@/components/Common/LoadingDialog.vue'
import time from '@/lib/utils/time'
@@ -144,6 +157,7 @@ export default {
GraphIcon,
SettingsIcon,
ExportToSvgIcon,
ViewCellValueIcon,
PngIcon,
HtmlIcon,
ClipboardIcon,
@@ -172,7 +186,8 @@ export default {
graph: this.initMode === 'graph' ? this.initOptions : null
},
showLoadingDialog: false,
showViewSettings: true
showViewSettings: true,
viewValuePanelVisible: false
}
},
computed: {

View File

@@ -67,6 +67,15 @@
@color-change="settings.style.backgroundColor = $event"
/>
</Field>
<Field label="Highlight mode">
<Dropdown
:options="highlightModeOptions"
:value="settings.style.highlightMode"
className="test_highlight_mode_select"
@change="updateHighlightNodeMode"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Nodes">
@@ -237,7 +246,9 @@ import {
buildNodes,
buildEdges,
updateNodes,
updateEdges
updateEdges,
reduceNodes,
reduceEdges
} from '@/lib/graphHelper'
import Graph from 'graphology'
import { circular, random, circlepack } from 'graphology-layout'
@@ -277,7 +288,7 @@ export default {
initOptions: Object,
showViewSettings: Boolean
},
emits: ['update'],
emits: ['update', 'selectItem', 'clearSelection'],
data() {
return {
graph: new Graph({ multi: true, allowSelfLoops: true }),
@@ -300,7 +311,10 @@ export default {
circlepack: CirclePackLayoutSettings,
forceAtlas2: ForceAtlasLayoutSettings
}),
selectedNodeId: undefined,
hoveredNodeId: undefined,
selectedEdgeId: undefined,
hoveredEdgeId: undefined,
settings: this.initOptions
? JSON.parse(JSON.stringify(this.initOptions))
: {
@@ -312,6 +326,7 @@ export default {
},
style: {
backgroundColor: 'white',
highlightMode: 'node_and_neighbors',
nodes: {
size: {
type: 'constant',
@@ -352,7 +367,15 @@ export default {
random: null,
circlepack: null,
forceAtlas2: null
},
highlightModeOptions: markRaw([
{ label: 'Node alone', value: 'node_alone' },
{ label: 'Node and neighbors', value: 'node_and_neighbors' },
{
label: 'Include edges between neighbors',
value: 'include_neighbor_edges'
}
])
}
},
computed: {
@@ -379,6 +402,46 @@ export default {
}, new Set())
return Array.from(keySet)
},
neighborsOfSelectedNode() {
if (this.settings.style.highlightMode === 'node_alone') {
return undefined
}
return this.selectedNodeId
? new Set(this.graph.neighbors(this.selectedNodeId))
: undefined
},
neighborsOfHoveredNode() {
if (this.settings.style.highlightMode === 'node_alone') {
return undefined
}
return this.hoveredNodeId
? new Set(this.graph.neighbors(this.hoveredNodeId))
: undefined
},
hoveredEdgeExtremities() {
return this.hoveredEdgeId
? this.graph.extremities(this.hoveredEdgeId)
: []
},
selectedEdgeExtremities() {
return this.selectedEdgeId
? this.graph.extremities(this.selectedEdgeId)
: []
},
interactionState() {
return {
selectedNodeId: this.selectedNodeId,
hoveredNodeId: this.hoveredNodeId,
selectedEdgeId: this.selectedEdgeId,
hoveredEdgeId: this.hoveredEdgeId,
neighborsOfSelectedNode: this.neighborsOfSelectedNode,
neighborsOfHoveredNode: this.neighborsOfHoveredNode,
hoveredEdgeExtremities: this.hoveredEdgeExtremities,
selectedEdgeExtremities: this.selectedEdgeExtremities
}
}
},
watch: {
@@ -423,6 +486,7 @@ export default {
},
methods: {
buildGraph() {
this.clearSelection()
if (this.renderer) {
this.renderer.kill()
}
@@ -440,12 +504,85 @@ export default {
renderEdgeLabels: true,
allowInvalidContainer: true,
labelColor: { attribute: 'labelColor', color: '#444444' },
edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' }
edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' },
enableEdgeEvents: true,
zIndex: true,
nodeReducer: (node, data) =>
reduceNodes(node, data, this.interactionState, this.settings),
edgeReducer: (edge, data) =>
reduceEdges(
edge,
data,
this.interactionState,
this.settings,
this.graph
)
})
this.renderer.on('clickNode', ({ node }) => {
this.selectedNodeId = node
this.selectedEdgeId = undefined
this.$emit('selectItem', this.graph.getNodeAttributes(node).data)
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('clickEdge', ({ edge }) => {
this.selectedEdgeId = edge
this.selectedNodeId = undefined
this.$emit('selectItem', this.graph.getEdgeAttributes(edge).data)
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('clickStage', () => {
this.clearSelection()
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('enterNode', ({ node }) => {
this.hoveredNodeId = node
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('enterEdge', ({ edge }) => {
this.hoveredEdgeId = edge
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('leaveNode', () => {
this.hoveredNodeId = undefined
this.renderer.refresh({
skipIndexation: true
})
})
this.renderer.on('leaveEdge', () => {
this.hoveredEdgeId = undefined
this.renderer.refresh({
skipIndexation: true
})
})
if (this.settings.layout.type === 'forceAtlas2') {
this.autoRunFA2Layout()
}
},
clearSelection() {
this.selectedNodeId = undefined
this.selectedEdgeId = undefined
this.$emit('clearSelection')
},
updateHighlightNodeMode(mode) {
this.settings.style.highlightMode = mode
if (this.renderer) {
this.renderer.refresh({
skipIndexation: true
})
}
},
updateStructure(attributeName, value) {
this.settings.structure[attributeName] = value
},

View File

@@ -13,21 +13,38 @@
documentation</a
>.
</div>
<div
<splitpanes
:before="{ size: 70, max: 100 }"
:after="{ size: 30, max: 50, hidden: !showValueViewer }"
:default="{ before: 70, after: 30 }"
class="graph"
:style="{
height:
!dataSources || !dataSourceIsValid ? 'calc(100% - 40px)' : '100%'
}"
>
<template #left-pane>
<div ref="graphEditorContainer" :style="{ height: '100%' }">
<GraphEditor
ref="graphEditor"
:dataSources="dataSources"
:initOptions="initOptions"
:showViewSettings="showViewSettings"
@update="$emit('update')"
@select-item="selectedItem = $event"
@clear-selection="selectedItem = null"
/>
</div>
</template>
<template v-if="showValueViewer" #right-pane>
<value-viewer
:empty="!selectedItem"
emptyMessage="No node or edge selected to view"
:value="JSON.stringify(selectedItem)"
defaultFormat="json"
/>
</template>
</splitpanes>
</div>
</template>
@@ -35,17 +52,20 @@
import 'react-chart-editor/lib/react-chart-editor.css'
import GraphEditor from '@/components/Graph/GraphEditor.vue'
import { dataSourceIsValid } from '@/lib/graphHelper'
import ValueViewer from '@/components/ValueViewer.vue'
import Splitpanes from '@/components/Common/Splitpanes'
export default {
name: 'Graph',
components: { GraphEditor },
components: { GraphEditor, ValueViewer, Splitpanes },
props: {
dataSources: Object,
initOptions: Object,
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
exportToHtmlEnabled: Boolean,
showViewSettings: Boolean
showViewSettings: Boolean,
showValueViewer: Boolean
},
emits: [
'update:exportToSvgEnabled',
@@ -57,7 +77,8 @@ export default {
],
data() {
return {
resizeObserver: null
resizeObserver: null,
selectedItem: null
}
},
computed: {
@@ -83,10 +104,10 @@ export default {
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.graphContainer)
this.resizeObserver.observe(this.$refs.graphEditorContainer)
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.graphContainer)
this.resizeObserver.unobserve(this.$refs.graphEditorContainer)
},
methods: {
getOptionsForSave() {
@@ -100,7 +121,7 @@ export default {
return this.$refs.graphEditor.prepareCopy()
},
async handleResize() {
const renderer = this.$refs.graphEditor.renderer
const renderer = this.$refs.graphEditor?.renderer
if (renderer) {
renderer.refresh()
renderer.getCamera().animatedReset({ duration: 600 })

View File

@@ -1,27 +1,57 @@
<template>
<div ref="runResultPanel" class="run-result-panel">
<component
:is="viewValuePanelVisible ? 'splitpanes' : 'div'"
<splitpanes
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100, hidden: !viewValuePanelVisible }"
:default="{ before: 50, after: 50 }"
class="run-result-panel-content"
>
<template #left-pane>
<div class="result-set-container">
<div
:id="'run-result-left-pane-' + tab.id"
class="result-set-container"
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30" />
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]" />
<sql-table
v-if="result && !viewRecord"
:data-set="result"
:time="time"
:pageSize="pageSize"
:page="defaultPage"
:selectedCellCoordinates="defaultSelectedCell"
class="straight"
@update-selected-cell="onUpdateSelectedCell"
/>
<record
v-if="result && viewRecord"
ref="recordView"
:data-set="result"
:time="time"
:selectedColumnIndex="selectedCell ? +selectedCell.dataset.col : 0"
:rowIndex="selectedCell ? +selectedCell.dataset.row : 0"
@update-selected-cell="onUpdateSelectedCell"
/>
</div>
</template>
<div
:id="'run-result-result-set-' + tab.id"
class="result-set-container"
/>
<template v-if="viewValuePanelVisible" #right-pane>
<div class="value-viewer-container">
<value-viewer
v-show="selectedCell"
:cellValue="
:empty="!selectedCell"
emptyMessage="No cell selected to view"
:value="
selectedCell
? result.values[result.columns[selectedCell.dataset.col]][
selectedCell.dataset.row
@@ -29,12 +59,8 @@
: ''
"
/>
<div v-show="!selectedCell" class="table-preview">
No cell selected to view
</div>
</div>
</template>
</component>
</splitpanes>
<side-tool-bar panel="table" @switch-to="$emit('switchTo', $event)">
<icon-button
@@ -89,48 +115,6 @@
@action="copyToClipboard"
@cancel="cancelCopy"
/>
<teleport defer :to="resultSetTeleportTarget" :disabled="!enableTeleport">
<div>
<div
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30" />
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]" />
<sql-table
v-if="result && !viewRecord"
:data-set="result"
:time="time"
:pageSize="pageSize"
:page="defaultPage"
:selectedCellCoordinates="defaultSelectedCell"
class="straight"
@update-selected-cell="onUpdateSelectedCell"
/>
<record
v-if="result && viewRecord"
ref="recordView"
:data-set="result"
:time="time"
:selectedColumnIndex="selectedCell ? +selectedCell.dataset.col : 0"
:rowIndex="selectedCell ? +selectedCell.dataset.row : 0"
@update-selected-cell="onUpdateSelectedCell"
/>
</div>
</teleport>
</div>
</template>
@@ -151,7 +135,7 @@ import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time'
import loadingDialog from '@/components/Common/LoadingDialog'
import events from '@/lib/utils/events'
import ValueViewer from './ValueViewer'
import ValueViewer from '@/components/ValueViewer'
import Record from './Record/index.vue'
export default {
@@ -172,7 +156,6 @@ export default {
Splitpanes
},
props: {
tab: Object,
result: Object,
isGettingResults: Boolean,
error: Object,
@@ -194,20 +177,6 @@ export default {
showLoadingDialog: false
}
},
computed: {
resultSetTeleportTarget() {
if (!this.enableTeleport) {
return undefined
}
const base = `#${
this.viewValuePanelVisible
? 'run-result-left-pane'
: 'run-result-result-set'
}`
const tabIdPostfix = `-${this.tab.id}`
return base + tabIdPostfix
}
},
watch: {
result() {
this.defaultSelectedCell = null
@@ -332,19 +301,12 @@ export default {
width: 0;
}
.result-set-container,
.result-set-container > div {
.result-set-container {
position: relative;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.value-viewer-container {
height: 100%;
width: 100%;
background-color: var(--color-white);
position: relative;
}
.table-preview {
position: absolute;

View File

@@ -37,7 +37,6 @@
:disabled="!enableTeleport"
>
<run-result
:tab="tab"
:result="tab.result"
:isGettingResults="tab.isGettingResults"
:error="tab.error"

View File

@@ -1,5 +1,6 @@
<template>
<div class="value-viewer">
<template v-if="!empty">
<div class="value-viewer-toolbar">
<button
v-for="format in formats"
@@ -12,7 +13,9 @@
{{ format.text }}
</button>
<button type="button" class="copy" @click="copyToClipboard">Copy</button>
<button type="button" class="copy" @click="copyToClipboard">
Copy
</button>
<button
type="button"
class="line-wrap"
@@ -44,6 +47,11 @@
class="messages"
/>
</div>
</template>
<div v-show="empty" class="empty-message">
{{ emptyMessage }}
</div>
</div>
</template>
@@ -60,12 +68,19 @@ import cIo from '@/lib/utils/clipboardIo'
import Logs from '@/components/Common/Logs'
export default {
name: 'ValueViewer',
components: {
Codemirror,
Logs
},
props: {
cellValue: [String, Number, Uint8Array]
value: [String, Number, Uint8Array],
empty: Boolean,
emptyMessage: String,
defaultFormat: {
type: String,
default: 'text'
}
},
data() {
return {
@@ -73,7 +88,7 @@ export default {
{ text: 'Text', value: 'text' },
{ text: 'JSON', value: 'json' }
],
currentFormat: 'text',
currentFormat: this.defaultFormat,
lineWrapping: false,
formattedJson: '',
messages: []
@@ -94,13 +109,13 @@ export default {
}
},
isBlob() {
return this.cellValue && ArrayBuffer.isView(this.cellValue)
return this.value && ArrayBuffer.isView(this.value)
},
isNull() {
return this.cellValue === null
return this.value === null
},
cellText() {
const value = this.cellValue
const value = this.value
if (this.isNull) {
return 'NULL'
}
@@ -111,17 +126,23 @@ export default {
}
},
watch: {
currentFormat() {
currentFormat: {
immediate: true,
handler() {
this.messages = []
this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
this.formatJson(this.value)
}
}
},
cellValue() {
value: {
immediate: true,
handler() {
this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
this.formatJson(this.value)
}
}
}
},
@@ -141,7 +162,7 @@ export default {
},
copyToClipboard() {
cIo.copyText(
this.currentFormat === 'json' ? this.formattedJson : this.cellValue,
this.currentFormat === 'json' ? this.formattedJson : this.value,
'The value is copied to clipboard.'
)
}
@@ -153,8 +174,10 @@ export default {
.value-viewer {
background-color: var(--color-white);
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.value-viewer-toolbar {
display: flex;
@@ -219,4 +242,14 @@ export default {
width: 1px;
background: var(--color-text-base);
}
.empty-message {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 13px;
text-align: center;
}
</style>

View File

@@ -127,6 +127,119 @@ export function updateEdges(graph, attributeUpdates) {
})
}
export function reduceNodes(nodeId, nodeData, interactionState, settings) {
const {
selectedNodeId,
hoveredNodeId,
selectedEdgeId,
hoveredEdgeId,
neighborsOfSelectedNode,
neighborsOfHoveredNode,
selectedEdgeExtremities,
hoveredEdgeExtremities
} = interactionState
const res = { ...nodeData }
if (selectedNodeId || hoveredNodeId || hoveredEdgeId || selectedEdgeId) {
res.zIndex = 2
res.highlighted = nodeId === selectedNodeId || nodeId === hoveredNodeId
const isInHoveredFamily =
nodeId === hoveredNodeId ||
neighborsOfHoveredNode?.has(nodeId) ||
hoveredEdgeExtremities.includes(nodeId)
const isInSelectedFamily =
nodeId === selectedNodeId ||
neighborsOfSelectedNode?.has(nodeId) ||
selectedEdgeExtremities.includes(nodeId)
if (isInSelectedFamily || isInHoveredFamily) {
res.forceLabel = true
} else {
res.color = getDiminishedColor(
nodeData.color,
settings.style.backgroundColor
)
res.label = ''
res.zIndex = 1
}
}
return res
}
export function reduceEdges(
edgeId,
edgeData,
interactionState,
settings,
graph
) {
const {
selectedNodeId,
hoveredNodeId,
selectedEdgeId,
hoveredEdgeId,
neighborsOfSelectedNode,
neighborsOfHoveredNode
} = interactionState
const res = { ...edgeData }
if (hoveredEdgeId || selectedEdgeId || selectedNodeId || hoveredNodeId) {
const extremities = graph.extremities(edgeId)
res.zIndex = 2
const isHighlighted = hoveredEdgeId === edgeId || selectedEdgeId === edgeId
let isVisible
if (settings.style.highlightMode === 'node_alone') {
isVisible = isHighlighted
} else if (settings.style.highlightMode === 'node_and_neighbors') {
isVisible =
isHighlighted ||
(selectedNodeId && extremities.includes(selectedNodeId)) ||
(hoveredNodeId && extremities.includes(hoveredNodeId))
} else {
isVisible =
isHighlighted ||
(selectedNodeId &&
extremities.every(
n => n === selectedNodeId || neighborsOfSelectedNode.has(n)
)) ||
(hoveredNodeId &&
extremities.every(
n => n === hoveredNodeId || neighborsOfHoveredNode.has(n)
))
}
if (isHighlighted) {
res.size = res.size * 2
res.forceLabel = true
} else if (!isVisible) {
res.color = getDiminishedColor(
edgeData.color,
settings.style.backgroundColor
)
res.zIndex = 1
res.label = ''
}
}
return res
}
export function getDiminishedColor(color, bgColor) {
const colorObj = tinycolor(color)
const colorOpacity = colorObj.getAlpha()
colorObj.setAlpha(0.25 * colorOpacity)
const fg = colorObj.toRgb()
const bg = tinycolor(bgColor).toRgb()
const r = Math.round(fg.r * fg.a + bg.r * (1 - fg.a))
const g = Math.round(fg.g * fg.a + bg.g * (1 - fg.a))
const b = Math.round(fg.b * fg.a + bg.b * (1 - fg.a))
return tinycolor({ r, g, b, a: 1 }).toHexString()
}
function getUpdateLabelMethod(labelSettings) {
const { source, color } = labelSettings
return attributes => {

View File

@@ -12,6 +12,7 @@ export default {
inquiries.forEach(inquiry => {
if (inquiry.viewType === 'graph') {
inquiry.viewOptions.style.nodes.color.opacity = 100
inquiry.viewOptions.style.highlightMode = 'node_and_neighbors'
}
})
}

View File

@@ -69,6 +69,8 @@ export default {
// Turn data into array if they are not
inquiryList = !Array.isArray(inquiries) ? [inquiries] : inquiries
inquiryList = migrate(1, inquiryList)
} else if (inquiries.version === 2) {
inquiryList = migrate(2, inquiries.inquiries)
} else {
inquiryList = inquiries.inquiries || []
}
@@ -108,6 +110,8 @@ export default {
if (!data.version) {
return data.length > 0 ? migrate(1, data) : []
} else if (data.version === 2) {
return migrate(2, data.inquiries)
} else {
return data.inquiries
}

View File

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

View File

@@ -418,4 +418,52 @@ describe('DataView.vue', () => {
wrapper.findComponent({ name: 'graph' }).props('initOptions')
).to.eql({ test_options: 'latest_graph_options' })
})
it('switches visibility of node or edge in graph mode', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
// viewNodeOrEdgeBtn is not disaplyed in chart mode
expect(
wrapper.findComponent({ ref: 'viewNodeOrEdgeBtn' }).exists()
).to.equal(false)
// Switch to pivot
const pivotBtn = wrapper.findComponent({ ref: 'pivotBtn' })
await pivotBtn.trigger('click')
// viewNodeOrEdgeBtn is not disaplyed in pivot mode
expect(
wrapper.findComponent({ ref: 'viewNodeOrEdgeBtn' }).exists()
).to.equal(false)
// Switch to graph
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
// viewNodeOrEdgeBtn is disaplyed in graph mode
const viewNodeOrEdgeBtn = wrapper.findComponent({
ref: 'viewNodeOrEdgeBtn'
})
expect(viewNodeOrEdgeBtn.exists()).to.equal(true)
// by default node viewer is hidden
expect(wrapper.findComponent({ name: 'value-viewer' }).exists()).to.equal(
false
)
// Click to show node viewer
await viewNodeOrEdgeBtn.trigger('click')
expect(wrapper.findComponent({ name: 'value-viewer' }).exists()).to.equal(
true
)
wrapper.unmount()
})
})

View File

@@ -2,6 +2,7 @@ import { expect } from 'chai'
import sinon from 'sinon'
import { mount, flushPromises } from '@vue/test-utils'
import Graph from '@/components/Graph/index.vue'
import { nextTick } from 'vue'
function getPixels(canvas) {
const context = canvas.getContext('webgl2')
@@ -163,6 +164,326 @@ describe('Graph.vue', () => {
wrapper.unmount()
})
it('the graph resizes when node viewer visibillity togglles', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
},
layout: {
type: 'circular',
options: null
}
},
showValueViewer: false
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const canvas = wrapper.find('canvas.sigma-nodes').wrapperElement
const initialCanvasWidth = canvas.scrollWidth
await wrapper.setProps({ showValueViewer: true })
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialCanvasWidth)
await wrapper.setProps({ showValueViewer: false })
await flushPromises()
expect(canvas.scrollWidth).to.equal(initialCanvasWidth)
wrapper.unmount()
})
it('the graph resizes when the split pane resizes', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
},
layout: {
type: 'circular',
options: null
}
},
showValueViewer: true
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const container =
wrapper.find('.graph-container').wrapperElement.parentElement
const canvas = wrapper.find('canvas.sigma-nodes').wrapperElement
const initialContainerWidth = container.scrollWidth
const initialCanvasWidth = canvas.scrollWidth
await wrapper.find('.splitpanes-splitter').trigger('mousedown')
document.dispatchEvent(
new MouseEvent('mousemove', {
clientX: initialContainerWidth / 2,
clientY: 80
})
)
document.dispatchEvent(new MouseEvent('mouseup'))
await nextTick()
await nextTick()
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialCanvasWidth)
wrapper.unmount()
})
it('opens and closes node viewer', async () => {
const wrapper = mount(Graph, {
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
},
layout: {
type: 'circular',
options: null
}
},
showValueViewer: false
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
expect(wrapper.text()).not.contain('No node or edge selected to view')
await wrapper.setProps({ showValueViewer: true })
expect(wrapper.text()).contains('No node or edge selected to view')
})
it('passes selected item to node viewer', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
},
layout: {
type: 'circular',
options: null
}
},
showValueViewer: true
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
expect(wrapper.find('.value-viewer .value-body').exists()).to.equal(false)
await wrapper
.findComponent({ ref: 'graphEditor' })
.vm.$emit('selectItem', { object_type: 0, node_id: 'Gryffindor' })
expect(wrapper.find('.value-viewer .value-body').text()).contains(
'"object_type": 0,'
)
expect(wrapper.find('.value-viewer .value-body').text()).contains(
'"node_id": "Gryffindor"'
)
await wrapper
.findComponent({ ref: 'graphEditor' })
.vm.$emit('clearSelection')
expect(wrapper.find('.value-viewer .value-body').exists()).to.equal(false)
wrapper.unmount()
})
it('nodes and edges are rendered', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,

View File

@@ -18,7 +18,6 @@ describe('RunResult.vue', () => {
sinon.spy(window, 'alert')
const wrapper = mount(RunResult, {
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -54,7 +53,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -114,7 +112,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -154,7 +151,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -196,7 +192,6 @@ describe('RunResult.vue', () => {
it('shows value of selected cell - result set', async () => {
const wrapper = mount(RunResult, {
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -250,9 +245,9 @@ describe('RunResult.vue', () => {
// Click on 'bar' cell again
await rows[1].findAll('td')[1].trigger('click')
expect(
wrapper.find('.value-viewer-container .table-preview').text()
).to.equals('No cell selected to view')
expect(wrapper.find('.value-viewer').text()).to.equals(
'No cell selected to view'
)
wrapper.unmount()
})
@@ -260,7 +255,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
@@ -323,9 +317,9 @@ describe('RunResult.vue', () => {
// Click on 'foo' cell again
await rows[1].find('td').trigger('click')
expect(
wrapper.find('.value-viewer-container .table-preview').text()
).to.equals('No cell selected to view')
expect(wrapper.find('.value-viewer').text()).to.equals(
'No cell selected to view'
)
wrapper.unmount()
})
@@ -333,7 +327,6 @@ describe('RunResult.vue', () => {
const wrapper = mount(RunResult, {
attachTo: document.body,
props: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {

View File

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

View File

@@ -1215,4 +1215,982 @@ describe('graphHelper.js', () => {
}
])
})
it('getDiminishedColor', () => {
const diminishedColor = graphHelper.getDiminishedColor(
'#FF0000CC',
'#0000FF'
)
expect(diminishedColor).to.equal('#3300cc')
})
it('reduceNodes', () => {
const settings = {
style: {
backgroundColor: '#0000FF'
}
}
const nodeData = { color: '#FF0000CC', label: 'Node label' }
let interactionState = {
selectedNodeId: 'node-1',
hoveredNodeId: 'node-2',
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: new Set(['node-1.1']),
neighborsOfHoveredNode: new Set(['node-2.1']),
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceNodes('node-1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: true,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-2', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: true,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-1.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-2.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-3', nodeData, interactionState, settings)
).to.eql({
color: '#3300cc',
label: '',
zIndex: 1,
highlighted: false
})
interactionState = {
selectedNodeId: 'node-1',
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: 'edge-2-2.1',
neighborsOfSelectedNode: new Set(['node-1.1']),
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: ['node-2', 'node-2.1']
}
expect(
graphHelper.reduceNodes('node-1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: true,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-2', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-1.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-1.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-3', nodeData, interactionState, settings)
).to.eql({
color: '#3300cc',
label: '',
zIndex: 1,
highlighted: false
})
interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: 'edge-1-1.1',
hoveredEdgeId: 'edge-2-2.1',
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: ['node-1', 'node-1.1'],
hoveredEdgeExtremities: ['node-2', 'node-2.1']
}
expect(
graphHelper.reduceNodes('node-1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-2', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-1.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-2.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-3', nodeData, interactionState, settings)
).to.eql({
color: '#3300cc',
label: '',
zIndex: 1,
highlighted: false
})
interactionState = {
selectedNodeId: null,
hoveredNodeId: 'node-2',
selectedEdgeId: 'edge-1',
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: new Set(['node-2.1']),
selectedEdgeExtremities: ['node-1', 'node-1.1'],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceNodes('node-1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-2', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: true,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-1.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-2.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label',
zIndex: 2,
highlighted: false,
forceLabel: true
})
expect(
graphHelper.reduceNodes('node-3', nodeData, interactionState, settings)
).to.eql({
color: '#3300cc',
label: '',
zIndex: 1,
highlighted: false
})
interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceNodes('node-1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label'
})
expect(
graphHelper.reduceNodes('node-2', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label'
})
expect(
graphHelper.reduceNodes('node-1.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label'
})
expect(
graphHelper.reduceNodes('node-2.1', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label'
})
expect(
graphHelper.reduceNodes('node-3', nodeData, interactionState, settings)
).to.eql({
color: '#FF0000CC',
label: 'Node label'
})
})
it('reduceEdges - node_alone', () => {
const settings = {
style: {
backgroundColor: '#0000FF',
highlightMode: 'node_alone'
}
}
const edgeData = { color: '#FF0000CC', label: 'Edge label', size: 3 }
const graph = new Graph()
graph.addNode('node-1.1')
graph.addNode('node-1.2')
graph.addNode('node-1.3')
graph.addNode('node-2.1')
graph.addNode('node-2.2')
const edge1112 = graph.addEdge('node-1.1', 'node-1.2', edgeData)
const edge1113 = graph.addEdge('node-1.1', 'node-1.3', edgeData)
const edge1213 = graph.addEdge('node-1.2', 'node-1.3', edgeData)
const edge2122 = graph.addEdge('node-2.1', 'node-2.2', edgeData)
const hiddenEdgeStyle = { color: '#3300cc', label: '', zIndex: 1, size: 3 }
const highlightedEdgeStyle = {
color: '#FF0000CC',
label: 'Edge label',
zIndex: 2,
size: 6,
forceLabel: true
}
let interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: edge2122,
hoveredEdgeId: edge1112,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: ['node-2.1', 'node-2.2'],
hoveredEdgeExtremities: ['node-1.1', 'node-1.2']
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(highlightedEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(highlightedEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
interactionState = {
selectedNodeId: 'node-1.1',
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: new Set(['node-1.2', 'node-1.3']),
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
interactionState = {
selectedNodeId: null,
hoveredNodeId: 'node-1.1',
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: new Set(['node-1.2', 'node-1.3']),
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
})
it('reduceEdges - node_and_neighbors', () => {
const settings = {
style: {
backgroundColor: '#0000FF',
highlightMode: 'node_and_neighbors'
}
}
const edgeData = { color: '#FF0000CC', label: 'Edge label', size: 3 }
const graph = new Graph()
graph.addNode('node-1.1')
graph.addNode('node-1.2')
graph.addNode('node-1.3')
graph.addNode('node-2.1')
graph.addNode('node-2.2')
const edge1112 = graph.addEdge('node-1.1', 'node-1.2', edgeData)
const edge1113 = graph.addEdge('node-1.1', 'node-1.3', edgeData)
const edge1213 = graph.addEdge('node-1.2', 'node-1.3', edgeData)
const edge2122 = graph.addEdge('node-2.1', 'node-2.2', edgeData)
const hiddenEdgeStyle = { color: '#3300cc', label: '', zIndex: 1, size: 3 }
const highlightedEdgeStyle = {
color: '#FF0000CC',
label: 'Edge label',
zIndex: 2,
size: 6,
forceLabel: true
}
const visibleEdgeStyle = {
color: '#FF0000CC',
label: 'Edge label',
zIndex: 2,
size: 3
}
let interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: edge2122,
hoveredEdgeId: edge1112,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: ['node-2.1', 'node-2.2'],
hoveredEdgeExtremities: ['node-1.1', 'node-1.2']
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(highlightedEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(highlightedEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
interactionState = {
selectedNodeId: 'node-1.1',
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: new Set(['node-1.2', 'node-1.3']),
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
interactionState = {
selectedNodeId: null,
hoveredNodeId: 'node-1.1',
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: new Set(['node-1.2', 'node-1.3']),
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
})
it('reduceEdges - include_neighbor_edges', () => {
const settings = {
style: {
backgroundColor: '#0000FF',
highlightMode: 'include_neighbor_edges'
}
}
const edgeData = { color: '#FF0000CC', label: 'Edge label', size: 3 }
const graph = new Graph()
graph.addNode('node-1.1')
graph.addNode('node-1.2')
graph.addNode('node-1.3')
graph.addNode('node-2.1')
graph.addNode('node-2.2')
const edge1112 = graph.addEdge('node-1.1', 'node-1.2', edgeData)
const edge1113 = graph.addEdge('node-1.1', 'node-1.3', edgeData)
const edge1213 = graph.addEdge('node-1.2', 'node-1.3', edgeData)
const edge2122 = graph.addEdge('node-2.1', 'node-2.2', edgeData)
const hiddenEdgeStyle = { color: '#3300cc', label: '', zIndex: 1, size: 3 }
const highlightedEdgeStyle = {
color: '#FF0000CC',
label: 'Edge label',
zIndex: 2,
size: 6,
forceLabel: true
}
const visibleEdgeStyle = {
color: '#FF0000CC',
label: 'Edge label',
zIndex: 2,
size: 3
}
let interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: edge2122,
hoveredEdgeId: edge1112,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: ['node-2.1', 'node-2.2'],
hoveredEdgeExtremities: ['node-1.1', 'node-1.2']
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(highlightedEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(highlightedEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
interactionState = {
selectedNodeId: 'node-1.1',
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: new Set(['node-1.2', 'node-1.3']),
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
interactionState = {
selectedNodeId: null,
hoveredNodeId: 'node-1.1',
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: new Set(['node-1.2', 'node-1.3']),
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(hiddenEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(visibleEdgeStyle)
interactionState = {
selectedNodeId: null,
hoveredNodeId: null,
selectedEdgeId: null,
hoveredEdgeId: null,
neighborsOfSelectedNode: undefined,
neighborsOfHoveredNode: undefined,
selectedEdgeExtremities: [],
hoveredEdgeExtremities: []
}
expect(
graphHelper.reduceEdges(
edge2122,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1112,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1113,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
expect(
graphHelper.reduceEdges(
edge1213,
edgeData,
interactionState,
settings,
graph
)
).to.eql(edgeData)
})
})

View File

@@ -39,4 +39,101 @@ describe('_migrations.js', () => {
}
])
})
it('migrates from version 2 to the current', () => {
const oldInquiries = [
{
id: '123',
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: { here_are: 'foo chart settings' },
createdAt: '2021-05-06T11:05:50.877Z'
},
{
id: '456',
name: 'bar',
query: 'SELECT * FROM bar',
viewType: 'graph',
viewOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: { type: 'constant', value: 10 },
color: {
type: 'calculated',
method: 'degree',
colorscale: null,
mode: 'continious',
colorscaleDirection: 'reversed'
},
label: { source: 'label', 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 }
},
createdAt: '2021-05-07T11:05:50.877Z'
}
]
expect(migrations._migrate(2, oldInquiries)).to.eql([
{
id: '123',
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: { here_are: 'foo chart settings' },
createdAt: '2021-05-06T11:05:50.877Z'
},
{
id: '456',
name: 'bar',
query: 'SELECT * FROM bar',
viewType: 'graph',
viewOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
highlightMode: 'node_and_neighbors',
nodes: {
size: { type: 'constant', value: 10 },
color: {
type: 'calculated',
method: 'degree',
colorscale: null,
mode: 'continious',
colorscaleDirection: 'reversed',
opacity: 100
},
label: { source: 'label', 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 }
},
createdAt: '2021-05-07T11:05:50.877Z'
}
])
})
})

View File

@@ -125,6 +125,7 @@ describe('storedInquiries.js', () => {
},
style: {
backgroundColor: 'white',
highlightMode: 'node_and_neighbors',
nodes: {
size: { type: 'constant', value: 10 },
color: {
@@ -256,7 +257,7 @@ describe('storedInquiries.js', () => {
})
})
it('deserialiseInquiries migrates inquiries', () => {
it('deserialiseInquiries migrates inquiries of v1', () => {
const str = `[
{
"id": 1,
@@ -295,7 +296,110 @@ describe('storedInquiries.js', () => {
])
})
it('deserialiseInquiries return array for one inquiry of v1', () => {
it('deserialiseInquiries migrates inquiries of v2', () => {
const str = `{
"version": 2,
"inquiries": [
{
"id": 1,
"name": "foo",
"query": "select * from foo",
"viewType": "chart",
"viewOptions": [],
"createdAt": "2020-11-03T14:17:49.524Z"
},
{
"id": "Xh1Hc9v7P3mRPZVM59QiC",
"query": "SELECT * from doc",
"viewType": "graph",
"viewOptions": {
"structure": {
"nodeId": "node_id",
"objectType": "object_type",
"edgeSource": "source",
"edgeTarget": "target"
},
"style": {
"backgroundColor": "white",
"nodes": {
"size": { "type": "constant", "value": 10 },
"color": {
"type": "calculated",
"method": "degree",
"colorscale": null,
"mode": "continious",
"colorscaleDirection": "reversed"
},
"label": { "source": "label", "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 }
},
"name": "student graph",
"createdAt": "2026-01-19T21:46:13.899Z"
}
]
}
`
const inquiry = storedInquiries.deserialiseInquiries(str)
expect(inquiry).to.eql([
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T14:17:49.524Z'
},
{
id: 'Xh1Hc9v7P3mRPZVM59QiC',
query: 'SELECT * from doc',
viewType: 'graph',
viewOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
highlightMode: 'node_and_neighbors',
nodes: {
size: { type: 'constant', value: 10 },
color: {
type: 'calculated',
method: 'degree',
colorscale: null,
mode: 'continious',
colorscaleDirection: 'reversed',
opacity: 100
},
label: { source: 'label', 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 }
},
name: 'student graph',
createdAt: '2026-01-19T21:46:13.899Z'
}
])
})
it('deserialiseInquiries returns array for one inquiry of v1', () => {
const str = `
{
"id": 1,
@@ -407,7 +511,7 @@ describe('storedInquiries.js', () => {
])
})
it('readPredefinedInquiries old', async () => {
it('readPredefinedInquiries v1', async () => {
const str = `[
{
"id": 1,
@@ -432,9 +536,9 @@ describe('storedInquiries.js', () => {
])
})
it('readPredefinedInquiries', async () => {
it('readPredefinedInquiries v2', async () => {
const str = `{
"version": 3,
"version": 2,
"inquiries": [
{
"id": 1,
@@ -443,7 +547,44 @@ describe('storedInquiries.js', () => {
"viewType": "chart",
"viewOptions": [],
"createdAt": "2020-11-03T14:17:49.524Z"
}]
},
{
"id": "Xh1Hc9v7P3mRPZVM59QiC",
"query": "SELECT * from doc",
"viewType": "graph",
"viewOptions": {
"structure": {
"nodeId": "node_id",
"objectType": "object_type",
"edgeSource": "source",
"edgeTarget": "target"
},
"style": {
"backgroundColor": "white",
"nodes": {
"size": { "type": "constant", "value": 10 },
"color": {
"type": "calculated",
"method": "degree",
"colorscale": null,
"mode": "continious",
"colorscaleDirection": "reversed"
},
"label": { "source": "label", "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 }
},
"name": "student graph",
"createdAt": "2026-01-19T21:46:13.899Z"
}
]
}
`
sinon.stub(fu, 'readFile').returns(Promise.resolve(new Response(str)))
@@ -457,6 +598,90 @@ describe('storedInquiries.js', () => {
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T14:17:49.524Z'
},
{
id: 'Xh1Hc9v7P3mRPZVM59QiC',
query: 'SELECT * from doc',
viewType: 'graph',
viewOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
highlightMode: 'node_and_neighbors',
nodes: {
size: { type: 'constant', value: 10 },
color: {
type: 'calculated',
method: 'degree',
colorscale: null,
mode: 'continious',
colorscaleDirection: 'reversed',
opacity: 100
},
label: { source: 'label', 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 }
},
name: 'student graph',
createdAt: '2026-01-19T21:46:13.899Z'
}
])
})
it('readPredefinedInquiries', async () => {
const str = `{
"version": 3,
"inquiries": [
{
"id": 1,
"name": "foo",
"query": "select * from foo",
"viewType": "chart",
"viewOptions": [],
"createdAt": "2020-11-03T14:17:49.524Z"
},
{
"id": 2,
"name": "boo",
"query": "select * from boo",
"viewType": "graph",
"viewOptions": {},
"createdAt": "2020-11-03T14:17:49.524Z"
}
]
}
`
sinon.stub(fu, 'readFile').returns(Promise.resolve(new Response(str)))
const inquiries = await storedInquiries.readPredefinedInquiries()
expect(fu.readFile.calledOnceWith('./inquiries.json')).to.equal(true)
expect(inquiries).to.eql([
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T14:17:49.524Z'
},
{
id: 2,
name: 'boo',
query: 'select * from boo',
viewType: 'graph',
viewOptions: {},
createdAt: '2020-11-03T14:17:49.524Z'
}
])
})