Add contextmenu in database explorer and implement deleting rows

This commit is contained in:
Tulir Asokan 2018-12-29 15:18:16 +02:00
parent 46186452dc
commit 147081c0db
6 changed files with 206 additions and 26 deletions

View file

@ -6,6 +6,7 @@
"node-sass": "^4.9.4", "node-sass": "^4.9.4",
"react": "^16.6.0", "react": "^16.6.0",
"react-ace": "^6.2.0", "react-ace": "^6.2.0",
"react-contextmenu": "^2.10.0",
"react-dom": "^16.6.0", "react-dom": "^16.6.0",
"react-json-tree": "^0.11.0", "react-json-tree": "^0.11.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",

View file

@ -15,6 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react" import React, { Component } from "react"
import { NavLink, Link, withRouter } from "react-router-dom" import { NavLink, Link, withRouter } from "react-router-dom"
import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu"
import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg" import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg"
import { ReactComponent as OrderDesc } from "../../res/sort-down.svg" import { ReactComponent as OrderDesc } from "../../res/sort-down.svg"
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg" import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
@ -87,7 +88,7 @@ class InstanceDatabase extends Component {
return order return order
} }
buildSQLQuery(table = this.state.selectedTable) { buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
let query = `SELECT * FROM ${table}` let query = `SELECT * FROM ${table}`
if (this.order.size > 0) { if (this.order.size > 0) {
@ -97,24 +98,25 @@ class InstanceDatabase extends Component {
} }
query += " LIMIT 100" query += " LIMIT 100"
this.setState({ query }, this.reloadContent) this.setState({ query }, () => this.reloadContent(resetContent))
} }
reloadContent = async () => { reloadContent = async (resetContent = true) => {
this.setState({ loading: true }) this.setState({ loading: true })
const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query) const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
this.setState({ this.setState({ loading: false })
loading: false, if (resetContent) {
prevQuery: null, this.setState({
rowCount: null, prevQuery: null,
insertedPrimaryKey: null, rowCount: null,
error: null, insertedPrimaryKey: null,
}) error: null,
})
}
if (!res.ok) { if (!res.ok) {
this.setState({ this.setState({
error: res.error, error: res.error,
}) })
this.buildSQLQuery()
} else if (res.rows) { } else if (res.rows) {
this.setState({ this.setState({
header: res.columns, header: res.columns,
@ -126,7 +128,7 @@ class InstanceDatabase extends Component {
rowCount: res.rowcount, rowCount: res.rowcount,
insertedPrimaryKey: res.insertedPrimaryKey, insertedPrimaryKey: res.insertedPrimaryKey,
}) })
this.buildSQLQuery() this.buildSQLQuery(this.state.selectedTable, false)
} }
} }
@ -158,15 +160,96 @@ class InstanceDatabase extends Component {
} }
} }
getColumnInfo(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
if (column.primary) {
return <span className="meta">&nbsp;(pk)</span>
} else if (column.unique) {
return <span className="meta">&nbsp;(u)</span>
}
return null
}
getColumnType(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
return column.type
}
deleteRow = async (_, data) => {
const values = this.state.content[data.row]
const keys = this.state.header
const condition = []
for (const [index, key] of Object.entries(keys)) {
const val = values[index]
condition.push(`${key}='${this.sqlEscape(val.toString())}'`)
}
const query = `DELETE FROM ${this.state.selectedTable} WHERE ${condition.join(" AND ")}`
const res = await api.queryInstanceDatabase(this.props.instanceID, query)
this.setState({
prevQuery: `DELETE FROM ${this.state.selectedTable} ...`,
rowCount: res.rowcount,
})
await this.reloadContent(false)
}
editCell = async (evt, data) => {
console.log("Edit", data)
}
collectContextMeta = props => ({
row: props.row,
col: props.col,
})
sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => {
switch (char) {
case "\0":
return "\\0"
case "\x08":
return "\\b"
case "\x09":
return "\\t"
case "\x1a":
return "\\z"
case "\n":
return "\\n"
case "\r":
return "\\r"
case "\"":
case "'":
case "\\":
case "%":
return "\\" + char
default:
return char
}
})
renderTable = () => <div className="table"> renderTable = () => <div className="table">
{this.state.header ? ( {this.state.header ? <>
<table> <table>
<thead> <thead>
<tr> <tr>
{this.state.header.map(column => ( {this.state.header.map(column => (
<td key={column}> <td key={column}>
<span onClick={() => this.toggleSort(column)}> <span onClick={() => this.toggleSort(column)}
{column} title={this.getColumnType(column)}>
<strong>{column}</strong>
{this.getColumnInfo(column)}
{this.getSortIcon(column)} {this.getSortIcon(column)}
</span> </span>
</td> </td>
@ -174,18 +257,24 @@ class InstanceDatabase extends Component {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{this.state.content.map((row, index) => ( {this.state.content.map((row, rowIndex) => (
<tr key={index}> <tr key={rowIndex}>
{row.map((column, index) => ( {row.map((cell, colIndex) => (
<td key={index}> <ContextMenuTrigger key={colIndex} id="database_table_menu"
{column} renderTag="td" row={rowIndex} col={colIndex}
</td> collect={this.collectContextMeta}>
{cell}
</ContextMenuTrigger>
))} ))}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : this.state.loading ? <Spinner/> : null} <ContextMenu id="database_table_menu">
<MenuItem onClick={this.deleteRow}>Delete row</MenuItem>
<MenuItem disabled onClick={this.editCell}>Edit cell</MenuItem>
</ContextMenu>
</> : this.state.loading ? <Spinner/> : null}
</div> </div>
renderContent() { renderContent() {

View file

@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
@import lib/spinner @import lib/spinner
@import lib/contextmenu
@import base/vars @import base/vars
@import base/body @import base/body

View file

@ -0,0 +1,80 @@
.react-contextmenu {
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, .15);
border-radius: .25rem;
color: #373a3c;
font-size: 16px;
margin: 2px 0 0;
min-width: 160px;
outline: none;
opacity: 0;
padding: 5px 0;
pointer-events: none;
text-align: left;
transition: opacity 250ms ease !important;
}
.react-contextmenu.react-contextmenu--visible {
opacity: 1;
pointer-events: auto;
z-index: 9999;
}
.react-contextmenu-item {
background: 0 0;
border: 0;
color: #373a3c;
cursor: pointer;
font-weight: 400;
line-height: 1.5;
padding: 3px 20px;
text-align: inherit;
white-space: nowrap;
}
.react-contextmenu-item.react-contextmenu-item--active,
.react-contextmenu-item.react-contextmenu-item--selected {
color: #fff;
background-color: #20a0ff;
border-color: #20a0ff;
text-decoration: none;
}
.react-contextmenu-item.react-contextmenu-item--disabled,
.react-contextmenu-item.react-contextmenu-item--disabled:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
color: #878a8c;
}
.react-contextmenu-item--divider {
border-bottom: 1px solid rgba(0, 0, 0, .15);
cursor: inherit;
margin-bottom: 3px;
padding: 2px 0;
}
.react-contextmenu-item--divider:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
}
.react-contextmenu-item.react-contextmenu-submenu {
padding: 0;
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item {
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item:after {
content: "";
display: inline-block;
position: absolute;
right: 7px;
}
.example-multiple-targets::after {
content: attr(data-count);
display: block;
}

View file

@ -80,6 +80,9 @@
span.query span.query
font-family: "Fira Code", monospace font-family: "Fira Code", monospace
p
margin: 0
> div.table > div.table
overflow-x: auto overflow-x: auto
overflow-y: hidden overflow-y: hidden
@ -90,8 +93,6 @@
box-sizing: border-box box-sizing: border-box
> thead > thead
font-weight: bold
> tr > td > span > tr > td > span
align-items: center align-items: center
justify-items: center justify-items: center

View file

@ -4606,7 +4606,7 @@ gonzales-pe-sl@^4.2.3:
dependencies: dependencies:
minimist "1.1.x" minimist "1.1.x"
"gonzales-pe-sl@github:srowhani/gonzales-pe#dev": gonzales-pe-sl@srowhani/gonzales-pe#dev:
version "4.2.3" version "4.2.3"
resolved "https://codeload.github.com/srowhani/gonzales-pe/tar.gz/3b052416074edc280f7d04bbe40b2e410693c4a3" resolved "https://codeload.github.com/srowhani/gonzales-pe/tar.gz/3b052416074edc280f7d04bbe40b2e410693c4a3"
dependencies: dependencies:
@ -8579,7 +8579,7 @@ rc@^1.2.7:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
react-ace@^6.3.2: react-ace@^6.2.0:
version "6.3.2" version "6.3.2"
resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-6.3.2.tgz#4fc75edce17d79c3169791dc184744950aca4794" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-6.3.2.tgz#4fc75edce17d79c3169791dc184744950aca4794"
integrity sha512-eSk0fWvrBe2oqYIYX0njLddLG5H0hemWv5VVoQi5yDSPTjGlSSnzFwdgPyfuwRe8mSARZuRdprPQa5p61hKirw== integrity sha512-eSk0fWvrBe2oqYIYX0njLddLG5H0hemWv5VVoQi5yDSPTjGlSSnzFwdgPyfuwRe8mSARZuRdprPQa5p61hKirw==
@ -8611,6 +8611,14 @@ react-base16-styling@^0.5.1:
lodash.flow "^3.3.0" lodash.flow "^3.3.0"
pure-color "^1.2.0" pure-color "^1.2.0"
react-contextmenu@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/react-contextmenu/-/react-contextmenu-2.10.0.tgz#3a5338a552964db85c300072f719bc1f6b969838"
integrity sha512-neiZGpfxfYFjqbcIExi69qruqhB7l0LKEguHDXeizgyTGbJHTwbq1GplXCHIafUAkbGZH8FfD9PBeUcSRG78+Q==
dependencies:
classnames "^2.2.5"
object-assign "^4.1.0"
react-dev-utils@^6.0.5: react-dev-utils@^6.0.5:
version "6.1.1" version "6.1.1"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.1.1.tgz#a07e3e8923c4609d9f27e5af5207e3ca20724895" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.1.1.tgz#a07e3e8923c4609d9f27e5af5207e3ca20724895"