diff --git a/maubot/management/frontend/package.json b/maubot/management/frontend/package.json
index e754eef..fb7e13a 100644
--- a/maubot/management/frontend/package.json
+++ b/maubot/management/frontend/package.json
@@ -6,6 +6,7 @@
"node-sass": "^4.9.4",
"react": "^16.6.0",
"react-ace": "^6.2.0",
+ "react-contextmenu": "^2.10.0",
"react-dom": "^16.6.0",
"react-json-tree": "^0.11.0",
"react-router-dom": "^4.3.1",
diff --git a/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js b/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js
index a1e9a06..472d6f0 100644
--- a/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js
+++ b/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js
@@ -15,6 +15,7 @@
// along with this program. If not, see .
import React, { Component } from "react"
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 OrderDesc } from "../../res/sort-down.svg"
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
@@ -87,7 +88,7 @@ class InstanceDatabase extends Component {
return order
}
- buildSQLQuery(table = this.state.selectedTable) {
+ buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
let query = `SELECT * FROM ${table}`
if (this.order.size > 0) {
@@ -97,24 +98,25 @@ class InstanceDatabase extends Component {
}
query += " LIMIT 100"
- this.setState({ query }, this.reloadContent)
+ this.setState({ query }, () => this.reloadContent(resetContent))
}
- reloadContent = async () => {
+ reloadContent = async (resetContent = true) => {
this.setState({ loading: true })
const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
- this.setState({
- loading: false,
- prevQuery: null,
- rowCount: null,
- insertedPrimaryKey: null,
- error: null,
- })
+ this.setState({ loading: false })
+ if (resetContent) {
+ this.setState({
+ prevQuery: null,
+ rowCount: null,
+ insertedPrimaryKey: null,
+ error: null,
+ })
+ }
if (!res.ok) {
this.setState({
error: res.error,
})
- this.buildSQLQuery()
} else if (res.rows) {
this.setState({
header: res.columns,
@@ -126,7 +128,7 @@ class InstanceDatabase extends Component {
rowCount: res.rowcount,
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 (pk)
+ } else if (column.unique) {
+ return (u)
+ }
+ 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 = () =>
- {this.state.header ? (
+ {this.state.header ? <>
{this.state.header.map(column => (
- this.toggleSort(column)}>
- {column}
+ this.toggleSort(column)}
+ title={this.getColumnType(column)}>
+ {column}
+ {this.getColumnInfo(column)}
{this.getSortIcon(column)}
|
@@ -174,18 +257,24 @@ class InstanceDatabase extends Component {
- {this.state.content.map((row, index) => (
-
- {row.map((column, index) => (
-
- {column}
- |
+ {this.state.content.map((row, rowIndex) => (
+
+ {row.map((cell, colIndex) => (
+
))}
))}
- ) : this.state.loading ?
: null}
+
+ > : this.state.loading ?
: null}
renderContent() {
diff --git a/maubot/management/frontend/src/style/index.sass b/maubot/management/frontend/src/style/index.sass
index 9c1e193..d7b702d 100644
--- a/maubot/management/frontend/src/style/index.sass
+++ b/maubot/management/frontend/src/style/index.sass
@@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
@import lib/spinner
+@import lib/contextmenu
@import base/vars
@import base/body
diff --git a/maubot/management/frontend/src/style/lib/contextmenu.scss b/maubot/management/frontend/src/style/lib/contextmenu.scss
new file mode 100644
index 0000000..5575fb4
--- /dev/null
+++ b/maubot/management/frontend/src/style/lib/contextmenu.scss
@@ -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;
+}
diff --git a/maubot/management/frontend/src/style/pages/instance-database.sass b/maubot/management/frontend/src/style/pages/instance-database.sass
index 428dac9..50c75ef 100644
--- a/maubot/management/frontend/src/style/pages/instance-database.sass
+++ b/maubot/management/frontend/src/style/pages/instance-database.sass
@@ -80,6 +80,9 @@
span.query
font-family: "Fira Code", monospace
+ p
+ margin: 0
+
> div.table
overflow-x: auto
overflow-y: hidden
@@ -90,8 +93,6 @@
box-sizing: border-box
> thead
- font-weight: bold
-
> tr > td > span
align-items: center
justify-items: center
diff --git a/maubot/management/frontend/yarn.lock b/maubot/management/frontend/yarn.lock
index 60aec45..54df298 100644
--- a/maubot/management/frontend/yarn.lock
+++ b/maubot/management/frontend/yarn.lock
@@ -4606,7 +4606,7 @@ gonzales-pe-sl@^4.2.3:
dependencies:
minimist "1.1.x"
-"gonzales-pe-sl@github:srowhani/gonzales-pe#dev":
+gonzales-pe-sl@srowhani/gonzales-pe#dev:
version "4.2.3"
resolved "https://codeload.github.com/srowhani/gonzales-pe/tar.gz/3b052416074edc280f7d04bbe40b2e410693c4a3"
dependencies:
@@ -8579,7 +8579,7 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
-react-ace@^6.3.2:
+react-ace@^6.2.0:
version "6.3.2"
resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-6.3.2.tgz#4fc75edce17d79c3169791dc184744950aca4794"
integrity sha512-eSk0fWvrBe2oqYIYX0njLddLG5H0hemWv5VVoQi5yDSPTjGlSSnzFwdgPyfuwRe8mSARZuRdprPQa5p61hKirw==
@@ -8611,6 +8611,14 @@ react-base16-styling@^0.5.1:
lodash.flow "^3.3.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:
version "6.1.1"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.1.1.tgz#a07e3e8923c4609d9f27e5af5207e3ca20724895"