1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-07 02:28:54 +08:00

26 Commits

Author SHA1 Message Date
lana-k
a2464d839f #115 fix version number 2024-01-07 13:55:38 +01:00
lana-k
316e603c3c #115 style fixes 2024-01-07 13:37:21 +01:00
lana-k
88466eca5e #115 fix lint 2024-01-07 12:31:53 +01:00
lana-k
5123e39a60 #115 version 2024-01-07 12:14:08 +01:00
lana-k
4c8401f32f #115 scroll record to beginning 2024-01-06 20:36:43 +01:00
lana-k
d949629ee4 #115 fix new lines - use pre 2024-01-06 18:55:45 +01:00
lana-k
7a18e415c8 #115 add styles for blob and null 2024-01-06 16:51:35 +01:00
lana-k
878689b3f7 fix svg button state 2024-01-06 12:03:06 +01:00
lana-k
42f040975d #115 tests 2024-01-06 11:23:23 +01:00
lana-k
78e9ca2120 #115 fix tests 2024-01-03 18:26:07 +01:00
lana-k
96af391f20 #115 clear message 2024-01-02 13:57:42 +01:00
lana-k
f58b62eb0c #115 add messages 2023-12-27 23:00:05 +01:00
lana-k
b17040d3ef #115 copy cell value 2023-12-27 22:22:49 +01:00
lana-k
bc6154b9ad #115 add icons 2023-12-27 21:30:43 +01:00
lana-k
3aea8c951b #115 update value when switch row 2023-12-26 20:45:11 +01:00
lana-k
1e982a1196 #115 unselect on paging 2023-10-31 22:27:47 +01:00
lana-k
6ecbde7fd3 #115 style fixes 2023-10-31 20:48:30 +01:00
lana-k
5ee881432a #115 select cell between modes; pass record number 2023-10-29 20:01:51 +01:00
lana-k
735e4ec7f6 #115 record and row navigator 2023-10-28 22:51:28 +02:00
lana-k
07d31dbfe9 #115 unselect 2023-10-28 19:48:36 +02:00
lana-k
ac1f7de62c #115 formats and call selections 2023-10-27 22:50:54 +02:00
lana-k
96877de532 #115 move focus 2023-10-27 18:47:45 +02:00
lana-k
b60fc28e47 #115 json view 2023-10-27 17:14:14 +02:00
lana-k
bec3d9c737 #115 add split in result set 2023-10-25 20:43:22 +02:00
lana-k
8aac7af481 update package.json 2023-07-03 23:33:52 +02:00
lana-k
6982204e68 Update currentTab when close tabs #112 2023-07-03 23:13:09 +02:00
25 changed files with 1524 additions and 221 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "sqliteviz", "name": "sqliteviz",
"version": "0.23.1", "version": "0.24.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -107,3 +107,9 @@ table.sqliteviz-table {
font-size: 11px; font-size: 11px;
color: var(--color-text-base); color: var(--color-text-base);
} }
.sqliteviz-table tbody td[data-isNull="true"],
.sqliteviz-table tbody td[data-isBlob="true"] {
color: var(--color-text-light-2);
font-style: italic;
}

View File

@@ -1,6 +1,7 @@
<template> <template>
<div <button
:class="['icon-btn', { active }, { disabled }]" :class="['icon-btn', { active }]"
:disabled="disabled"
@click="onClick" @click="onClick"
@mouseenter="showTooltip($event, tooltipPosition)" @mouseenter="showTooltip($event, tooltipPosition)"
@mouseleave="hideTooltip" @mouseleave="hideTooltip"
@@ -12,7 +13,7 @@
<span v-if="tooltip" class="icon-tooltip" :style="tooltipStyle" ref="tooltip"> <span v-if="tooltip" class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
{{ tooltip }} {{ tooltip }}
</span> </span>
</div> </button>
</template> </template>
<script> <script>
@@ -38,11 +39,12 @@ export default {
box-sizing: border-box; box-sizing: border-box;
width: 26px; width: 26px;
height: 26px; height: 26px;
cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
background-color: transparent;
border: none;
} }
.icon-btn:hover { .icon-btn:hover {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -56,12 +58,12 @@ export default {
fill: var(--color-accent); fill: var(--color-accent);
} }
.disabled.icon-btn .icon >>> path, .icon-btn:disabled .icon >>> path,
.disabled.icon-btn .icon >>> circle { .icon-btn:disabled .icon >>> circle {
fill: var(--color-border); fill: var(--color-border);
} }
.disabled.icon-btn { .icon-btn:disabled {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
} }

View File

@@ -18,7 +18,12 @@
ref="table-container" ref="table-container"
@scroll="onScrollTable" @scroll="onScrollTable"
> >
<table ref="table" class="sqliteviz-table"> <table
ref="table"
class="sqliteviz-table"
tabindex="0"
@keydown="onTableKeydown"
>
<thead> <thead>
<tr> <tr>
<th v-for="(th, index) in columns" :key="index" ref="th"> <th v-for="(th, index) in columns" :key="index" ref="th">
@@ -28,9 +33,18 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="rowIndex in currentPageData.count" :key="rowIndex"> <tr v-for="rowIndex in currentPageData.count" :key="rowIndex">
<td v-for="(col, colIndex) in columns" :key="colIndex"> <td
v-for="(col, colIndex) in columns"
:data-col="colIndex"
:data-row="pageSize * (currentPage - 1) + rowIndex - 1"
:data-isNull="isNull(getCellValue(col, rowIndex))"
:data-isBlob="isBlob(getCellValue(col, rowIndex))"
:key="colIndex"
:aria-selected="false"
@click="onCellClick"
>
<div class="cell-data" :style="cellStyle"> <div class="cell-data" :style="cellStyle">
{{ dataSet.values[col][rowIndex - 1 + currentPageData.start] }} {{ getCellText(col, rowIndex) }}
</div> </div>
</td> </td>
</tr> </tr>
@@ -44,7 +58,11 @@
<span v-if="preview">for preview</span> <span v-if="preview">for preview</span>
<span v-if="time">in {{ time }}</span> <span v-if="time">in {{ time }}</span>
</div> </div>
<pager v-show="pageCount > 1" :page-count="pageCount" v-model="currentPage" /> <pager
v-show="pageCount > 1"
:page-count="pageCount"
v-model="currentPage"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -62,14 +80,20 @@ export default {
type: Number, type: Number,
default: 20 default: 20
}, },
preview: Boolean page: {
type: Number,
default: 1
},
preview: Boolean,
selectedCellCoordinates: Object
}, },
data () { data () {
return { return {
header: null, header: null,
tableWidth: null, tableWidth: null,
currentPage: 1, currentPage: this.page,
resizeObserver: null resizeObserver: null,
selectedCellElement: null
} }
}, },
computed: { computed: {
@@ -99,7 +123,40 @@ export default {
} }
} }
}, },
mounted () {
this.resizeObserver = new ResizeObserver(this.calculateHeadersWidth)
this.resizeObserver.observe(this.$refs.table)
this.calculateHeadersWidth()
if (this.selectedCellCoordinates) {
const { row, col } = this.selectedCellCoordinates
const cell = this.$refs.table
.querySelector(`td[data-col="${col}"][data-row="${row}"]`)
if (cell) {
this.selectCell(cell)
}
}
},
methods: { methods: {
isBlob (value) {
return value && ArrayBuffer.isView(value)
},
isNull (value) {
return value === null
},
getCellValue (col, rowIndex) {
return this.dataSet.values[col][rowIndex - 1 + this.currentPageData.start]
},
getCellText (col, rowIndex) {
const value = this.getCellValue(col, rowIndex)
if (this.isNull(value)) {
return 'NULL'
}
if (this.isBlob(value)) {
return 'BLOB'
}
return value
},
calculateHeadersWidth () { calculateHeadersWidth () {
this.tableWidth = this.$refs['table-container'].offsetWidth this.tableWidth = this.$refs['table-container'].offsetWidth
this.$nextTick(() => { this.$nextTick(() => {
@@ -110,18 +167,95 @@ export default {
}, },
onScrollTable () { onScrollTable () {
this.$refs['header-container'].scrollLeft = this.$refs['table-container'].scrollLeft this.$refs['header-container'].scrollLeft = this.$refs['table-container'].scrollLeft
},
onTableKeydown (e) {
const keyCodeMap = {
37: 'left',
39: 'right',
38: 'up',
40: 'down'
}
if (
!this.selectedCellElement ||
!Object.keys(keyCodeMap).includes(e.keyCode.toString())
) {
return
}
e.preventDefault()
this.moveFocusInTable(this.selectedCellElement, keyCodeMap[e.keyCode])
},
onCellClick (e) {
this.selectCell(e.target.closest('td'), false)
},
selectCell (cell, scrollTo = true) {
if (!cell) {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
this.selectedCellElement = cell
} else if (!cell.ariaSelected || cell.ariaSelected === 'false') {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
cell.ariaSelected = 'true'
this.selectedCellElement = cell
} else {
cell.ariaSelected = 'false'
this.selectedCellElement = null
}
if (this.selectedCellElement && scrollTo) {
this.selectedCellElement.scrollIntoView()
}
this.$emit('updateSelectedCell', this.selectedCellElement)
},
moveFocusInTable (initialCell, direction) {
const currentRowIndex = +initialCell.dataset.row
const currentColIndex = +initialCell.dataset.col
let newRowIndex, newColIndex
if (direction === 'right') {
if (currentColIndex === this.columns.length - 1) {
newRowIndex = currentRowIndex + 1
newColIndex = 0
} else {
newRowIndex = currentRowIndex
newColIndex = currentColIndex + 1
}
} else if (direction === 'left') {
if (currentColIndex === 0) {
newRowIndex = currentRowIndex - 1
newColIndex = this.columns.length - 1
} else {
newRowIndex = currentRowIndex
newColIndex = currentColIndex - 1
}
} else if (direction === 'up') {
newRowIndex = currentRowIndex - 1
newColIndex = currentColIndex
} else if (direction === 'down') {
newRowIndex = currentRowIndex + 1
newColIndex = currentColIndex
}
const newCell = this.$refs.table
.querySelector(`td[data-col="${newColIndex}"][data-row="${newRowIndex}"]`)
if (newCell) {
this.selectCell(newCell)
}
} }
}, },
mounted () {
this.resizeObserver = new ResizeObserver(this.calculateHeadersWidth)
this.resizeObserver.observe(this.$refs.table)
this.calculateHeadersWidth()
},
beforeDestroy () { beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.table) this.resizeObserver.unobserve(this.$refs.table)
}, },
watch: { watch: {
currentPageData: 'calculateHeadersWidth', currentPageData () {
this.calculateHeadersWidth()
this.selectCell(null)
},
dataSet () { dataSet () {
this.currentPage = 1 this.currentPage = 1
} }
@@ -130,4 +264,13 @@ export default {
</script> </script>
<style scoped> <style scoped>
table.sqliteviz-table:focus {
outline: none;
}
.sqliteviz-table tbody td:hover {
background-color: var(--color-bg-light-3);
}
.sqliteviz-table tbody td[aria-selected="true"] {
box-shadow:inset 0 0 0 1px var(--color-accent);
}
</style> </style>

View File

@@ -0,0 +1,20 @@
<template>
<svg
width="28"
height="27"
viewBox="0 0 28 27"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.9475 8.33625L12.7838 13.5L17.9475 18.6638L16.35 20.25L9.60001
13.5L16.35 6.75L17.9475 8.33625Z"
fill="#506784"
/>
</svg>
</template>
<script>
export default {
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<svg
width="27"
height="27"
viewBox="0 0 27 27"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.3474 8.33625L12.1837 13.5L17.3474 18.6638L15.7499 20.25L8.99991
13.5L15.7499 6.75L17.3474 8.33625Z"
fill="#506784"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.19995 19.8L7.19995 7.20001H9.19995V19.8H7.19995Z"
fill="#506784"
/>
</svg>
</template>
<script>
export default {
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<svg
width="19"
height="19"
viewBox="0 0 19 19"
fill="none"
>
<g clip-path="url(#clip0_2130_5292)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.85303 11.3794L1.85303 7.80371L5.86304 7.80371L5.86304
11.3794L1.85303 11.3794ZM7.36304 11.3794L7.36304 7.80371L11.3428
7.80371L11.3428 11.3794L7.36304 11.3794ZM12.8428 11.3794L16.853
11.3794L16.853 7.80371L12.8428 7.80371L12.8428 11.3794ZM15.353
6.30371L16.853 6.30371C17.6815 6.30371 18.353 6.97528 18.353
7.80371L18.353 11.3794C18.353 12.2078 17.6815 12.8794 16.853
12.8794L15.353 12.8794L15.353 14.3111C15.353 15.0153 14.7603 15.5916
14.0358 15.5916L4.67027 15.5916C3.94579 15.5916 3.35303 15.0153 3.35303
14.3111L3.35303 12.8794L1.85303 12.8794C1.0246 12.8794 0.353027 12.2078
0.353027 11.3794L0.353027 7.80371C0.353027 6.97528 1.0246 6.30371
1.85303 6.30371L3.35303 6.30371L3.35303 4.87201C3.35303 4.16349 3.94139
3.59155 4.67027 3.59155L14.0358 3.59155C14.7604 3.59155 15.353 4.16117
15.353 4.87201L15.353 6.30371ZM14.0315 6.30371L14.0315 4.87086L11.887
4.87086L11.887 6.30371L12.8428 6.30371L14.0315 6.30371ZM10.387
6.30371L10.387 4.87086L8.26685 4.87086L8.26685 6.30371L10.387
6.30371ZM6.76685 6.30371L6.76685 4.87086L4.67027 4.87086L4.67027
6.30371L6.76685 6.30371ZM4.67027 12.8794L4.67027 14.3121L6.76685
14.3121L6.76685 12.8794L4.67027 12.8794ZM8.26685 12.8794L8.26685
14.3121L10.387 14.3121L10.387 12.8794L8.26685 12.8794ZM11.887
12.8794L11.887 14.3121L14.0315 14.3121L14.0315 12.8794L11.887 12.8794Z"
fill="#A2B1C6"
/>
</g>
<defs>
<clipPath id="clip0_2130_5292">
<rect
width="18"
height="18"
fill="white"
transform="translate(0.353027 18.5916) rotate(-90)"
/>
</clipPath>
</defs>
</svg>
</template>
<script>
export default {
name: 'RowIcon'
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<svg
width="19"
height="19"
viewBox="0 0 19 19"
fill="none"
>
<g clip-path="url(#clip0_2131_6054)">
<path
d="M3.53784 11.5846L3.53784 3.14734L11.9751 3.14734V7.676C12.4655 7.51991
12.9771 7.47439 13.4751 7.53264V3.14734C13.4751 2.31891 12.8035 1.64734
11.9751 1.64734L3.53784 1.64734C2.70941 1.64734 2.03784 2.31891 2.03784
3.14734L2.03784 11.5846C2.03784 12.413 2.70942 13.0846 3.53784
13.0846H10.0831C9.771 12.6184 9.58279 12.1055 9.51083
11.5846H3.53784Z"
fill="#A2B1C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.7887 9.9291C15.4307 10.8837 15.1773 12.1779 14.2228
12.8199C13.2682 13.4618 11.974 13.2084 11.332 12.2539C10.69 11.2993
10.9434 10.0051 11.898 9.3631C12.8525 8.72113 14.1468 8.97454 14.7887
9.9291ZM14.4606 14.3901L16.6181 17.5982C16.8492 17.9419 17.3153 18.0331
17.659 17.802C18.0027 17.5708 18.0939 17.1048 17.8628 16.7611L15.6884
13.5279C16.7949 12.3365 16.9801 10.4996 16.0334 9.092C14.9292 7.45002
12.7029 7.01412 11.0609 8.1184C9.41891 9.22268 8.98302 11.449 10.0873
13.0909C11.062 14.5403 12.9109 15.05 14.4606 14.3901Z"
fill="#A2B1C6"
/>
</g>
<defs>
<clipPath id="clip0_2131_6054">
<rect
width="18"
height="18"
fill="white"
transform="translate(0.5 18.5916) rotate(-90)"
/>
</clipPath>
</defs>
</svg>
</template>
<script>
export default {
name: 'ViewCellValueIcon'
}
</script>

View File

@@ -8,11 +8,11 @@ function _getDataSourcesFromSqlResult (sqlResult) {
if (!sqlResult) { if (!sqlResult) {
return {} return {}
} }
const dataSorces = {} const dataSources = {}
sqlResult.columns.forEach((column, index) => { sqlResult.columns.forEach((column, index) => {
dataSorces[column] = sqlResult.values.map(row => row[index]) dataSources[column] = sqlResult.values.map(row => row[index])
}) })
return dataSorces return dataSources
} }
export default class Sql { export default class Sql {

View File

@@ -2,9 +2,11 @@ import Lib from 'plotly.js/src/lib'
import dataUrlToBlob from 'dataurl-to-blob' import dataUrlToBlob from 'dataurl-to-blob'
export default { export default {
async copyCsv (str) { async copyText (str, notifyMessage) {
await navigator.clipboard.writeText(str) await navigator.clipboard.writeText(str)
Lib.notifier('CSV copied to clipboard successfully', 'long') if (notifyMessage) {
Lib.notifier(notifyMessage, 'long')
}
}, },
async copyImage (source) { async copyImage (source) {

View File

@@ -36,9 +36,11 @@ export default {
state.currentTabId = state.tabs[index - 1].id state.currentTabId = state.tabs[index - 1].id
} else { } else {
state.currentTabId = null state.currentTabId = null
state.currentTab = null
state.untitledLastIndex = 0 state.untitledLastIndex = 0
} }
state.currentTab = state.currentTabId
? state.tabs.find(tab => tab.id === state.currentTabId)
: null
} }
state.tabs.splice(index, 1) state.tabs.splice(index, 1)
}, },

View File

@@ -77,6 +77,7 @@ export default {
}, },
{ deep: true } { deep: true }
) )
this.$emit('update:importToSvgEnabled', true)
}, },
mounted () { mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize) this.resizeObserver = new ResizeObserver(this.handleResize)

View File

@@ -41,7 +41,6 @@
> >
<png-icon /> <png-icon />
</icon-button> </icon-button>
<icon-button <icon-button
:disabled="!importToSvgEnabled" :disabled="!importToSvgEnabled"
tooltip="Save as SVG" tooltip="Save as SVG"

View File

@@ -0,0 +1,69 @@
<template>
<div class="record-navigator">
<icon-button
:disabled="value === 0"
tooltip="First row"
tooltip-position="top-left"
class="first"
@click="$emit('input', 0)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="value === 0"
tooltip="Previous row"
tooltip-position="top-left"
class="prev"
@click="$emit('input', value - 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="value === total - 1"
tooltip="Next row"
tooltip-position="top-left"
class="next"
@click="$emit('input', value + 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="value === total - 1"
tooltip="Last row"
tooltip-position="top-left"
class="last"
@click="$emit('input', total - 1)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
</div>
</template>
<script>
import IconButton from '@/components/IconButton'
import ArrowIcon from '@/components/svg/arrow'
import EdgeArrowIcon from '@/components/svg/edgeArrow'
export default {
components: {
IconButton,
ArrowIcon,
EdgeArrowIcon
},
props: {
value: Number,
total: Number
}
}
</script>
<style scoped>
.record-navigator {
display: flex;
}
.record-navigator .next,
.record-navigator .last {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="record-view">
<div class="table-container">
<table
ref="table"
class="sqliteviz-table"
tabindex="0"
@keydown="onTableKeydown"
>
<thead>
<tr>
<th/>
<th>
<div class="cell-data">
Row #{{ currentRowIndex + 1 }}
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(col, index) in columns" :key="index">
<th class="column-cell">{{ col }}</th>
<td
:data-col="index"
:data-row="currentRowIndex"
:data-isNull="isNull(getCellValue(col))"
:data-isBlob="isBlob(getCellValue(col))"
:key="index"
:aria-selected="false"
@click="onCellClick"
>
<div class="cell-data">
{{ getCellText(col) }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div class="table-footer-count">
{{ rowCount }} {{rowCount === 1 ? 'row' : 'rows'}} retrieved
<span v-if="time">in {{ time }}</span>
</div>
<row-navigator v-model="currentRowIndex" :total="rowCount"/>
</div>
</div>
</template>
<script>
import RowNavigator from './RowNavigator.vue'
export default {
components: { RowNavigator },
props: {
dataSet: Object,
time: String,
rowIndex: { type: Number, default: 0 },
selectedColumnIndex: Number
},
data () {
return {
selectedCellElement: null,
currentRowIndex: this.rowIndex
}
},
computed: {
columns () {
return this.dataSet.columns
},
rowCount () {
return this.dataSet.values[this.columns[0]].length
}
},
mounted () {
const col = this.selectedColumnIndex
const row = this.currentRowIndex
const cell = this.$refs.table
.querySelector(`td[data-col="${col}"][data-row="${row}"]`)
if (cell) {
this.selectCell(cell)
}
},
watch: {
async currentRowIndex () {
await this.$nextTick()
if (this.selectedCellElement) {
const previouslySelected = this.selectedCellElement
this.selectCell(null)
this.selectCell(previouslySelected)
}
}
},
methods: {
isBlob (value) {
return value && ArrayBuffer.isView(value)
},
isNull (value) {
return value === null
},
getCellValue (col) {
return this.dataSet.values[col][this.currentRowIndex]
},
getCellText (col) {
const value = this.getCellValue(col)
if (this.isNull(value)) {
return 'NULL'
}
if (this.isBlob(value)) {
return 'BLOB'
}
return value
},
onTableKeydown (e) {
const keyCodeMap = {
38: 'up',
40: 'down'
}
if (
!this.selectedCellElement ||
!Object.keys(keyCodeMap).includes(e.keyCode.toString())
) {
return
}
e.preventDefault()
this.moveFocusInTable(this.selectedCellElement, keyCodeMap[e.keyCode])
},
onCellClick (e) {
this.selectCell(e.target.closest('td'), false)
},
selectCell (cell, scrollTo = true) {
if (!cell) {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
this.selectedCellElement = cell
} else if (!cell.ariaSelected || cell.ariaSelected === 'false') {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
cell.ariaSelected = 'true'
this.selectedCellElement = cell
} else {
cell.ariaSelected = 'false'
this.selectedCellElement = null
}
if (this.selectedCellElement && scrollTo) {
this.selectedCellElement.scrollIntoView()
this.selectedCellElement.closest('.table-container').scrollTo({ left: 0 })
}
this.$emit('updateSelectedCell', this.selectedCellElement)
},
moveFocusInTable (initialCell, direction) {
const currentColIndex = +initialCell.dataset.col
const newColIndex = direction === 'up'
? currentColIndex - 1
: currentColIndex + 1
const newCell = this.$refs.table
.querySelector(`td[data-col="${newColIndex}"][data-row="${this.currentRowIndex}"]`)
if (newCell) {
this.selectCell(newCell)
}
}
}
}
</script>
<style scoped>
table.sqliteviz-table:focus {
outline: none;
}
.sqliteviz-table tbody td:hover {
background-color: var(--color-bg-light-3);
}
.sqliteviz-table tbody td[aria-selected="true"] {
box-shadow: inset 0 0 0 1px var(--color-accent);
}
table.sqliteviz-table {
margin-top: 0;
}
.sqliteviz-table thead tr th {
border-bottom: 1px solid var(--color-border-light);
}
.sqliteviz-table tbody tr th {
font-size: 14px;
font-weight: 600;
box-sizing: border-box;
background-color: var(--color-bg-dark);
color: var(--color-text-light);
border-bottom: 1px solid var(--color-border-light);
border-right: 1px solid var(--color-border-light);
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.table-footer {
align-items: center;
}
.record-view {
display: flex;
flex-direction: column;
height: 100%;
}
.table-container {
flex-grow: 1;
overflow: auto;
}
.column-cell {
max-width: 150px;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="value-viewer">
<div class="value-viewer-toolbar">
<button
v-for="format in formats"
:key="format.value"
type="button"
:aria-selected="currentFormat === format.value"
:class="format.value"
@click="currentFormat = format.value"
>
{{ format.text }}
</button>
<button
type="button"
class="copy"
@click="copyToClipboard"
>
Copy
</button>
</div>
<div class="value-body">
<codemirror
v-if="currentFormat === 'json' && formattedJson"
:value="formattedJson"
:options="cmOptions"
class="json-value"
/>
<pre
v-if="currentFormat === 'text'"
:class="['text-value', { 'meta-value': isNull || isBlob }]"
>{{ cellText }}</pre>
<logs
v-if="messages && messages.length > 0"
:messages="messages"
class="messages"
/>
</div>
</div>
</template>
<script>
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/javascript/javascript.js'
import 'codemirror/addon/fold/foldcode.js'
import 'codemirror/addon/fold/foldgutter.js'
import 'codemirror/addon/fold/foldgutter.css'
import 'codemirror/addon/fold/brace-fold.js'
import 'codemirror/theme/neo.css'
import cIo from '@/lib/utils/clipboardIo'
import Logs from '@/components/Logs'
export default {
components: {
codemirror,
Logs
},
props: {
cellValue: [String, Number, Uint8Array]
},
data () {
return {
formats: [
{ text: 'Text', value: 'text' },
{ text: 'JSON', value: 'json' }
],
currentFormat: 'text',
cmOptions: {
tabSize: 4,
mode: { name: 'javascript', json: true },
theme: 'neo',
lineNumbers: true,
line: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
readOnly: true
},
formattedJson: '',
messages: []
}
},
computed: {
isBlob () {
return this.cellValue && ArrayBuffer.isView(this.cellValue)
},
isNull () {
return this.cellValue === null
},
cellText () {
const value = this.cellValue
if (this.isNull) {
return 'NULL'
}
if (this.isBlob) {
return 'BLOB'
}
return value
}
},
watch: {
currentFormat () {
this.messages = []
this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
},
cellValue () {
this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
}
},
methods: {
formatJson (jsonStr) {
try {
this.formattedJson = JSON.stringify(
JSON.parse(jsonStr), null, 4
)
} catch (e) {
console.error(e)
this.formattedJson = ''
this.messages = [{
type: 'error',
message: 'Can\'t parse JSON.'
}]
}
},
copyToClipboard () {
cIo.copyText(this.currentFormat === 'json'
? this.formattedJson
: this.cellValue,
'The value is copied to clipboard.'
)
}
}
}
</script>
<style scoped>
.value-viewer {
background-color: var(--color-white);
height: 100%;
display: flex;
flex-direction: column;
}
.value-viewer-toolbar {
display: flex;
justify-content: end;
}
.value-body {
flex-grow: 1;
overflow: auto;
}
.text-value {
padding: 0 8px;
margin: 0;
color: var(--color-text-base);
}
.json-value {
margin-top: -4px;
}
.text-value.meta-value {
font-style: italic;
color: var(--color-text-light-2);
}
.messages {
margin: 0 8px;
}
.value-viewer-toolbar button {
font-size: 10px;
height: 20px;
padding: 0 8px;
border: none;
background: transparent;
color: var(--color-text-base);
border-radius: var(--border-radius-small);
}
.value-viewer-toolbar button:hover {
background-color: var(--color-bg-light);
}
.value-viewer-toolbar button[aria-selected="true"] {
color: var(--color-accent);
}
>>> .vue-codemirror {
height: 100%;
max-height: 100%;
}
>>> .CodeMirror {
height: 100%;
max-height: 100%;
}
>>> .CodeMirror-cursor {
width: 1px;
background: var(--color-text-base);
}
</style>

View File

@@ -1,31 +1,31 @@
<template> <template>
<div class="run-result-panel" ref="runResultPanel"> <div class="run-result-panel" ref="runResultPanel">
<div class="run-result-panel-content"> <component
<div :is="viewValuePanelVisible ? 'splitpanes':'div'"
v-show="result === null && !isGettingResults && !error" :before="{ size: 50, max: 100 }"
class="table-preview result-before" :after="{ size: 50, max: 100 }"
> :default="{ before: 50, after: 50 }"
Run your query and get results here class="run-result-panel-content"
</div> >
<div v-if="isGettingResults" class="table-preview result-in-progress"> <template #left-pane>
<loading-indicator :size="30"/> <div :id="'run-result-left-pane-'+tab.id" class="result-set-container"/>
Fetching results... </template>
</div> <div :id="'run-result-result-set-'+tab.id" class="result-set-container"/>
<div <template #right-pane v-if="viewValuePanelVisible">
v-show="result === undefined && !isGettingResults && !error" <div class="value-viewer-container">
class="table-preview result-empty" <value-viewer
> v-show="selectedCell"
No rows retrieved according to your query :cellValue="selectedCell
</div> ? result.values[result.columns[selectedCell.dataset.col]][selectedCell.dataset.row]
<logs v-if="error" :messages="[error]"/> : ''"
<sql-table />
v-if="result" <div v-show="!selectedCell" class="table-preview">
:data-set="result" No cell selected to view
:time="time" </div>
:pageSize="pageSize" </div>
class="straight" </template>
/> </component>
</div>
<side-tool-bar @switchTo="$emit('switchTo', $event)" panel="table"> <side-tool-bar @switchTo="$emit('switchTo', $event)" panel="table">
<icon-button <icon-button
:disabled="!result" :disabled="!result"
@@ -44,6 +44,26 @@
> >
<clipboard-icon/> <clipboard-icon/>
</icon-button> </icon-button>
<icon-button
:disabled="!result"
tooltip="View record"
tooltip-position="top-left"
:active="viewRecord"
@click="toggleViewRecord"
>
<row-icon/>
</icon-button>
<icon-button
:disabled="!result"
tooltip="View value"
tooltip-position="top-left"
:active="viewValuePanelVisible"
@click="toggleViewValuePanel"
>
<view-cell-value-icon/>
</icon-button>
</side-tool-bar> </side-tool-bar>
<loading-dialog <loading-dialog
@@ -56,6 +76,48 @@
@action="copyToClipboard" @action="copyToClipboard"
@cancel="cancelCopy" @cancel="cancelCopy"
/> />
<teleport :to="resultSetTeleportTarget">
<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"
:selected-cell-coordinates="defaultSelectedCell"
class="straight"
@updateSelectedCell="onUpdateSelectedCell"
/>
<record
ref="recordView"
v-if="result && viewRecord"
:data-set="result"
:time="time"
:selected-column-index="selectedCell ? +selectedCell.dataset.col : 0"
:rowIndex="selectedCell ? +selectedCell.dataset.row : 0"
@updateSelectedCell="onUpdateSelectedCell"
/>
</div>
</teleport>
</div> </div>
</template> </template>
@@ -63,9 +125,12 @@
import Logs from '@/components/Logs' import Logs from '@/components/Logs'
import SqlTable from '@/components/SqlTable' import SqlTable from '@/components/SqlTable'
import LoadingIndicator from '@/components/LoadingIndicator' import LoadingIndicator from '@/components/LoadingIndicator'
import SideToolBar from './SideToolBar' import SideToolBar from '../SideToolBar'
import Splitpanes from '@/components/Splitpanes'
import ExportToCsvIcon from '@/components/svg/exportToCsv' import ExportToCsvIcon from '@/components/svg/exportToCsv'
import ClipboardIcon from '@/components/svg/clipboard' import ClipboardIcon from '@/components/svg/clipboard'
import ViewCellValueIcon from '@/components/svg/viewCellValue'
import RowIcon from '@/components/svg/row'
import IconButton from '@/components/IconButton' import IconButton from '@/components/IconButton'
import csv from '@/lib/csv' import csv from '@/lib/csv'
import fIo from '@/lib/utils/fileIo' import fIo from '@/lib/utils/fileIo'
@@ -73,16 +138,30 @@ import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time' import time from '@/lib/utils/time'
import loadingDialog from '@/components/LoadingDialog' import loadingDialog from '@/components/LoadingDialog'
import events from '@/lib/utils/events' import events from '@/lib/utils/events'
import Teleport from 'vue2-teleport'
import ValueViewer from './ValueViewer'
import Record from './Record/index.vue'
export default { export default {
name: 'RunResult', name: 'RunResult',
props: ['result', 'isGettingResults', 'error', 'time'], props: {
tab: Object,
result: Object,
isGettingResults: Boolean,
error: Object,
time: [String, Number]
},
data () { data () {
return { return {
resizeObserver: null, resizeObserver: null,
pageSize: 20, pageSize: 20,
preparingCopy: false, preparingCopy: false,
dataToCopy: null dataToCopy: null,
viewValuePanelVisible: false,
selectedCell: null,
viewRecord: false,
defaultPage: 1,
defaultSelectedCell: null
} }
}, },
components: { components: {
@@ -93,7 +172,23 @@ export default {
ExportToCsvIcon, ExportToCsvIcon,
IconButton, IconButton,
ClipboardIcon, ClipboardIcon,
loadingDialog ViewCellValueIcon,
RowIcon,
loadingDialog,
ValueViewer,
Record,
Splitpanes,
Teleport
},
computed: {
resultSetTeleportTarget () {
const base = `#${this.viewValuePanelVisible
? 'run-result-left-pane'
: 'run-result-result-set'
}`
const tabIdPostfix = `-${this.tab.id}`
return base + tabIdPostfix
}
}, },
mounted () { mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize) this.resizeObserver = new ResizeObserver(this.handleResize)
@@ -103,6 +198,12 @@ export default {
beforeDestroy () { beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.runResultPanel) this.resizeObserver.unobserve(this.$refs.runResultPanel)
}, },
watch: {
result () {
this.defaultSelectedCell = null
this.selectedCell = null
}
},
methods: { methods: {
handleResize () { handleResize () {
this.calculatePageSize() this.calculatePageSize()
@@ -160,13 +261,35 @@ export default {
}, },
copyToClipboard () { copyToClipboard () {
cIo.copyCsv(this.dataToCopy) cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
this.$modal.hide('prepareCSVCopy') this.$modal.hide('prepareCSVCopy')
}, },
cancelCopy () { cancelCopy () {
this.dataToCopy = null this.dataToCopy = null
this.$modal.hide('prepareCSVCopy') this.$modal.hide('prepareCSVCopy')
},
toggleViewValuePanel () {
this.viewValuePanelVisible = !this.viewValuePanelVisible
},
toggleViewRecord () {
if (this.viewRecord) {
this.defaultSelectedCell = {
row: this.$refs.recordView.currentRowIndex,
col: this.selectedCell ? +this.selectedCell.dataset.col : 0
}
this.defaultPage = Math.ceil(
(this.$refs.recordView.currentRowIndex + 1) / this.pageSize
)
}
this.viewRecord = !this.viewRecord
},
onUpdateSelectedCell (e) {
this.selectedCell = e
} }
} }
} }
@@ -180,12 +303,24 @@ export default {
} }
.run-result-panel-content { .run-result-panel-content {
position: relative;
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;
width: 0; width: 0;
}
.result-set-container,
.result-set-container > div {
position: relative;
height: 100%;
width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.value-viewer-container {
height: 100%;
width: 100%;
background-color: var(--color-white);
position: relative;
}
.table-preview { .table-preview {
position: absolute; position: absolute;
@@ -194,6 +329,7 @@ export default {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: var(--color-text-base); color: var(--color-text-base);
font-size: 13px; font-size: 13px;
text-align: center;
} }
.result-in-progress { .result-in-progress {

View File

@@ -29,6 +29,7 @@
<teleport :to="`#${tab.layout.table}-${tab.id}`"> <teleport :to="`#${tab.layout.table}-${tab.id}`">
<run-result <run-result
:tab="tab"
:result="tab.result" :result="tab.result"
:is-getting-results="tab.isGettingResults" :is-getting-results="tab.isGettingResults"
:error="tab.error" :error="tab.error"

View File

@@ -7,9 +7,9 @@ describe('clipboardIo.js', async () => {
sinon.restore() sinon.restore()
}) })
it('copyCsv', async () => { it('copyText', async () => {
sinon.stub(navigator.clipboard, 'writeText').resolves(true) sinon.stub(navigator.clipboard, 'writeText').resolves(true)
await cIo.copyCsv('id\tname\r\n1\t2') await cIo.copyText('id\tname\r\n1\t2')
expect(navigator.clipboard.writeText.calledOnceWith('id\tname\r\n1\t2')) expect(navigator.clipboard.writeText.calledOnceWith('id\tname\r\n1\t2'))
}) })

View File

@@ -176,13 +176,15 @@ describe('mutations', () => {
const state = { const state = {
tabs: [tab1, tab2], tabs: [tab1, tab2],
currentTabId: 1 currentTabId: 1,
currentTab: tab1
} }
deleteTab(state, tab1) deleteTab(state, tab1)
expect(state.tabs).to.have.lengthOf(1) expect(state.tabs).to.have.lengthOf(1)
expect(state.tabs[0].id).to.equal(2) expect(state.tabs[0].id).to.equal(2)
expect(state.currentTabId).to.equal(2) expect(state.currentTabId).to.equal(2)
expect(state.currentTab).to.eql(tab2)
}) })
it('deleteTab - opened, last', () => { it('deleteTab - opened, last', () => {
@@ -208,13 +210,15 @@ describe('mutations', () => {
const state = { const state = {
tabs: [tab1, tab2], tabs: [tab1, tab2],
currentTabId: 2 currentTabId: 2,
currentTab: tab2
} }
deleteTab(state, tab2) deleteTab(state, tab2)
expect(state.tabs).to.have.lengthOf(1) expect(state.tabs).to.have.lengthOf(1)
expect(state.tabs[0].id).to.equal(1) expect(state.tabs[0].id).to.equal(1)
expect(state.currentTabId).to.equal(1) expect(state.currentTabId).to.equal(1)
expect(state.currentTab).to.eql(tab1)
}) })
it('deleteTab - opened, in the middle', () => { it('deleteTab - opened, in the middle', () => {
@@ -250,7 +254,8 @@ describe('mutations', () => {
const state = { const state = {
tabs: [tab1, tab2, tab3], tabs: [tab1, tab2, tab3],
currentTabId: 2 currentTabId: 2,
currentTab: tab2
} }
deleteTab(state, tab2) deleteTab(state, tab2)
@@ -258,6 +263,7 @@ describe('mutations', () => {
expect(state.tabs[0].id).to.equal(1) expect(state.tabs[0].id).to.equal(1)
expect(state.tabs[1].id).to.equal(3) expect(state.tabs[1].id).to.equal(3)
expect(state.currentTabId).to.equal(3) expect(state.currentTabId).to.equal(3)
expect(state.currentTab).to.eql(tab3)
}) })
it('deleteTab - opened, single', () => { it('deleteTab - opened, single', () => {
@@ -273,12 +279,14 @@ describe('mutations', () => {
const state = { const state = {
tabs: [tab1], tabs: [tab1],
currentTabId: 1 currentTabId: 1,
currentTab: tab1
} }
deleteTab(state, tab1) deleteTab(state, tab1)
expect(state.tabs).to.have.lengthOf(0) expect(state.tabs).to.have.lengthOf(0)
expect(state.currentTabId).to.equal(null) expect(state.currentTabId).to.equal(null)
expect(state.currentTab).to.equal(null)
}) })
it('setCurrentTabId', () => { it('setCurrentTabId', () => {

View File

@@ -54,6 +54,10 @@ describe('DataView.vue', () => {
const pivot = wrapper.findComponent({ name: 'pivot' }).vm const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsSvg') sinon.spy(pivot, 'saveAsSvg')
// Switch to Custom Chart renderer
pivot.pivotOptions.rendererName = 'Custom chart'
await pivot.$nextTick()
// Export to svg // Export to svg
await svgBtn.trigger('click') await svgBtn.trigger('click')
expect(pivot.saveAsSvg.calledOnce).to.equal(true) expect(pivot.saveAsSvg.calledOnce).to.equal(true)

View File

@@ -1,155 +0,0 @@
import { expect } from 'chai'
import { mount, createWrapper } from '@vue/test-utils'
import RunResult from '@/views/Main/Workspace/Tabs/Tab/RunResult'
import csv from '@/lib/csv'
import sinon from 'sinon'
describe('RunResult.vue', () => {
afterEach(() => {
sinon.restore()
})
it('shows alert when ClipboardItem is not supported', async () => {
const ClipboardItem = window.ClipboardItem
delete window.ClipboardItem
sinon.spy(window, 'alert')
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
expect(
window.alert.calledOnceWith(
"Your browser doesn't support copying into the clipboard. " +
'If you use Firefox you can enable it ' +
'by setting dom.events.asyncClipboard.clipboardItem to true.'
)
).to.equal(true)
window.ClipboardItem = ClipboardItem
})
it('copy to clipboard more than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Building message...
expect(wrapper.find('.dialog-body').text()).to.equal('Building CSV...')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
// Click copy
await wrapper.find('.dialog-buttons-container button.primary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
})
it('copy to clipboard less than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(500)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
})
it('cancel long copy', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// Click cancel
await wrapper.find('.dialog-buttons-container button.secondary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
})
})

View File

@@ -0,0 +1,116 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Record from '@/views/Main/Workspace/Tabs/Tab/RunResult/Record'
describe('Record.vue', () => {
it('shows record with selected cell', async () => {
const wrapper = mount(Record, {
propsData: {
dataSet: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
},
rowIndex: 1,
selectedColumnIndex: 1
}
})
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(2)
expect(rows.at(0).findAll('th').at(0).text()).to.equals('id')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('2')
expect(rows.at(1).findAll('th').at(0).text()).to.equals('name')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('bar')
const selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('bar')
})
it('switches to the next or previous row', async () => {
const wrapper = mount(Record, {
propsData: {
dataSet: {
columns: ['id', 'name'],
values: {
id: [1, 2, 3],
name: ['foo', 'bar', 'baz']
}
},
rowIndex: 0,
selectedColumnIndex: 0
}
})
let rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(2)
expect(rows.at(0).findAll('td').at(0).text()).to.equals('1')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('foo')
let selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('1')
await wrapper.find('.next').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('2')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('bar')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('2')
await wrapper.find('.prev').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('1')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('foo')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('1')
await wrapper.find('.last').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('3')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('baz')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('3')
await wrapper.find('.first').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('1')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('foo')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('1')
})
it('removes selection when click on selected cell', async () => {
const wrapper = mount(Record, {
propsData: {
dataSet: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
},
rowIndex: 1,
selectedColumnIndex: 1
}
})
const selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
await selectedCell.trigger('click')
const selectedCellAfterClick = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCellAfterClick.exists()).to.equals(false)
})
})

View File

@@ -0,0 +1,348 @@
import { expect } from 'chai'
import { mount, createWrapper } from '@vue/test-utils'
import RunResult from '@/views/Main/Workspace/Tabs/Tab/RunResult'
import csv from '@/lib/csv'
import sinon from 'sinon'
describe('RunResult.vue', () => {
afterEach(() => {
sinon.restore()
})
it('shows alert when ClipboardItem is not supported', async () => {
const ClipboardItem = window.ClipboardItem
delete window.ClipboardItem
sinon.spy(window, 'alert')
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
expect(
window.alert.calledOnceWith(
"Your browser doesn't support copying into the clipboard. " +
'If you use Firefox you can enable it ' +
'by setting dom.events.asyncClipboard.clipboardItem to true.'
)
).to.equal(true)
window.ClipboardItem = ClipboardItem
})
it('copy to clipboard more than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Building message...
expect(wrapper.find('.dialog-body').text()).to.equal('Building CSV...')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
// Click copy
await wrapper.find('.dialog-buttons-container button.primary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
})
it('copy to clipboard less than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(500)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
})
it('cancel long copy', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// Click cancel
await wrapper.find('.dialog-buttons-container button.secondary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
})
it('shows value of selected cell - result set', async () => {
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
}
}
})
// Open cell value panel
const viewValueBtn = createWrapper(
wrapper.findComponent({ name: 'viewCellValueIcon' }).vm.$parent
)
await viewValueBtn.trigger('click')
/*
Result set:
|1 | foo
+--+-----
|2 | bar
*/
// Click on '1' cell
const rows = wrapper.findAll('table tbody tr')
await rows.at(0).findAll('td').at(0).trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('1')
// Go to 'foo' with right arrow key
await wrapper.find('table').trigger('keydown.right')
expect(wrapper.find('.value-body').text()).to.equals('foo')
// Go to 'bar' with down arrow key
await wrapper.find('table').trigger('keydown.down')
expect(wrapper.find('.value-body').text()).to.equals('bar')
// Go to '2' with left arrow key
await wrapper.find('table').trigger('keydown.left')
expect(wrapper.find('.value-body').text()).to.equals('2')
// Go to '1' with up arrow key
await wrapper.find('table').trigger('keydown.up')
expect(wrapper.find('.value-body').text()).to.equals('1')
// Click on 'bar' cell
await rows.at(1).findAll('td').at(1).trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('bar')
// Click on 'bar' cell again
await rows.at(1).findAll('td').at(1).trigger('click')
expect(wrapper.find('.value-viewer-container .table-preview').text())
.to.equals('No cell selected to view')
})
it('shows value of selected cell - record view', async () => {
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
}
}
})
// Open cell value panel
const viewValueBtn = createWrapper(
wrapper.findComponent({ name: 'viewCellValueIcon' }).vm.$parent
)
await viewValueBtn.trigger('click')
// Go to record view
const vierRecordBtn = createWrapper(
wrapper.findComponent({ name: 'rowIcon' }).vm.$parent
)
await vierRecordBtn.trigger('click')
/*
Record 1:
|id | 1
+-----+-----
|name | foo
Record 2:
|id | 2
+-----+-----
|name | bar
*/
// Click '1' is selected by default
expect(wrapper.find('.value-body').text()).to.equals('1')
// Go to 'foo' with down arrow key
await wrapper.find('table').trigger('keydown.down')
expect(wrapper.find('.value-body').text()).to.equals('foo')
// Go to next record
await wrapper.find('.icon-btn.next').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.value-body').text()).to.equals('bar')
// Go to '2' with up arrow key
await wrapper.find('table').trigger('keydown.up')
expect(wrapper.find('.value-body').text()).to.equals('2')
// Go to prev record
await wrapper.find('.icon-btn.prev').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.value-body').text()).to.equals('1')
// Click on 'foo' cell
const rows = wrapper.findAll('table tbody tr')
await rows.at(1).find('td').trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('foo')
// Click on 'foo' cell again
await rows.at(1).find('td').trigger('click')
expect(wrapper.find('.value-viewer-container .table-preview').text())
.to.equals('No cell selected to view')
})
it('keeps selected cell when switch between record and regular view', async () => {
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [...Array(30)].map((x, i) => i),
name: [...Array(30)].map((x, i) => `name-${i}`)
}
}
}
})
// Open cell value panel
const viewValueBtn = createWrapper(
wrapper.findComponent({ name: 'viewCellValueIcon' }).vm.$parent
)
await viewValueBtn.trigger('click')
// Click on 'name-1' cell
const rows = wrapper.findAll('table tbody tr')
await rows.at(1).findAll('td').at(1).trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('name-1')
// Go to record view
const vierRecordBtn = createWrapper(
wrapper.findComponent({ name: 'rowIcon' }).vm.$parent
)
await vierRecordBtn.trigger('click')
// 'name-1' is selected
expect(wrapper.find('.value-body').text()).to.equals('name-1')
let selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('name-1')
// Go to last record
await wrapper.find('.icon-btn.last').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.value-body').text()).to.equals('name-29')
// Go to '29' with up arrow key
await wrapper.find('table').trigger('keydown.up')
expect(wrapper.find('.value-body').text()).to.equals('29')
// Go to regular view
await vierRecordBtn.trigger('click')
// '29' is selected
expect(wrapper.find('.value-body').text()).to.equals('29')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('29')
})
})

View File

@@ -0,0 +1,44 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import ValueViewer from '@/views/Main/Workspace/Tabs/Tab/RunResult/ValueViewer'
import sinon from 'sinon'
describe('ValueViewer.vue', () => {
afterEach(() => {
sinon.restore()
})
it('shows value in text mode', async () => {
const wrapper = mount(ValueViewer, {
propsData: {
cellValue: 'foo'
}
})
expect(wrapper.find('.value-body').text()).to.equals('foo')
})
it('shows error in json mode if the value is not json', async () => {
const wrapper = mount(ValueViewer, {
propsData: {
cellValue: 'foo'
}
})
await wrapper.find('button.json').trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('Can\'t parse JSON.')
})
it('copy to clipboard', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const wrapper = mount(ValueViewer, {
propsData: {
cellValue: 'foo'
}
})
await wrapper.find('button.copy').trigger('click')
expect(window.navigator.clipboard.writeText.calledOnceWith('foo'))
.to.equal(true)
})
})