Add contextmenu in database explorer and implement deleting rows
This commit is contained in:
parent
46186452dc
commit
147081c0db
6 changed files with 206 additions and 26 deletions
|
@ -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",
|
||||||
|
|
|
@ -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"> (pk)</span>
|
||||||
|
} else if (column.unique) {
|
||||||
|
return <span className="meta"> (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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
80
maubot/management/frontend/src/style/lib/contextmenu.scss
Normal file
80
maubot/management/frontend/src/style/lib/contextmenu.scss
Normal 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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue