mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 15:03:02 +00:00
Added changes for group properties editing
This commit is contained in:
256
jsx/src/components/DynamicTable/DynamicTable.jsx
Normal file
256
jsx/src/components/DynamicTable/DynamicTable.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from "react";
|
||||
import "./table-select.css";
|
||||
|
||||
function hasDuplicates(array) {
|
||||
var valuesSoFar = Object.create(null);
|
||||
for (var i = 0; i < array.length; ++i) {
|
||||
var value = array[i];
|
||||
if (value in valuesSoFar) {
|
||||
return true;
|
||||
}
|
||||
valuesSoFar[value] = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default class DynamicTable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.current_propobject = props.current_propobject;
|
||||
this.setProp = props.setProp;
|
||||
this.setPropKeys = props.setPropKeys;
|
||||
this.setPropValues = props.setPropValues;
|
||||
this.setChanged = props.setChanged;
|
||||
|
||||
let current_keys = [];
|
||||
let current_values = [];
|
||||
|
||||
for (var property in this.current_propobject) {
|
||||
current_keys.push(property);
|
||||
current_values.push(this.current_propobject[property]);
|
||||
}
|
||||
//current_keys = this.current_propobject.propkeys
|
||||
//current_values = this.current_propobject.propvalues
|
||||
|
||||
this.state = {
|
||||
message: "",
|
||||
message2: "",
|
||||
propkeys: current_keys,
|
||||
propvalues: current_values,
|
||||
propobject: "",
|
||||
};
|
||||
}
|
||||
|
||||
updateMessageKey(event) {
|
||||
this.setState({
|
||||
message: event.target.value,
|
||||
});
|
||||
}
|
||||
|
||||
updateMessageValue(event) {
|
||||
this.setState({
|
||||
message2: event.target.value,
|
||||
});
|
||||
}
|
||||
handleRefresh(i) {
|
||||
var propkeys = this.state.propkeys;
|
||||
var propvalues = this.state.propvalues;
|
||||
var propobject = {};
|
||||
propkeys.forEach((key, i) => (propobject[key] = propvalues[i]));
|
||||
|
||||
console.log(propobject);
|
||||
this.setProp(propobject);
|
||||
this.setPropKeys(propkeys);
|
||||
this.setPropValues(propvalues);
|
||||
this.setChanged(true);
|
||||
|
||||
this.setState({
|
||||
propkeys: propkeys,
|
||||
propvalues: propvalues,
|
||||
message: "",
|
||||
message2: "",
|
||||
propobject: propobject,
|
||||
});
|
||||
}
|
||||
handleClick() {
|
||||
var propkeys = this.state.propkeys;
|
||||
var propvalues = this.state.propvalues;
|
||||
if (this.state.message != "") {
|
||||
if (this.state.message2 != "") {
|
||||
propkeys.push(this.state.message);
|
||||
propvalues.push(this.state.message2);
|
||||
} else {
|
||||
console.log("Value not valid");
|
||||
}
|
||||
} else {
|
||||
console.log("Value not valid");
|
||||
}
|
||||
|
||||
var propobject = {};
|
||||
propkeys.forEach((key, i) => (propobject[key] = propvalues[i]));
|
||||
console.log(propobject);
|
||||
this.setProp(propobject);
|
||||
this.setPropKeys(propkeys);
|
||||
this.setPropValues(propvalues);
|
||||
this.setChanged(true);
|
||||
this.setState({
|
||||
propkeys: propkeys,
|
||||
propvalues: propvalues,
|
||||
message: "",
|
||||
message2: "",
|
||||
propobject: propobject,
|
||||
});
|
||||
}
|
||||
|
||||
handleValueChanged(i, event) {
|
||||
var propvalues = this.state.propvalues;
|
||||
var propkeys = this.state.propkeys;
|
||||
propvalues[i] = event.target.value;
|
||||
|
||||
this.handleRefresh();
|
||||
this.setPropKeys(propkeys);
|
||||
this.setPropValues(propvalues);
|
||||
this.setState({
|
||||
propvalues: propvalues,
|
||||
});
|
||||
}
|
||||
handleKeyChanged(i, event) {
|
||||
var propkeys = this.state.propkeys;
|
||||
|
||||
if (event.target.value != "") {
|
||||
propkeys[i] = event.target.value;
|
||||
}
|
||||
console.log(event.target.value);
|
||||
|
||||
if (event.target.value == "") {
|
||||
this.handleItemDeleted(i);
|
||||
}
|
||||
this.handleRefresh(i);
|
||||
this.setPropKeys(propkeys);
|
||||
this.setState({
|
||||
propkeys: propkeys,
|
||||
});
|
||||
}
|
||||
|
||||
handleItemDeleted(i) {
|
||||
var propvalues = this.state.propvalues;
|
||||
var propkeys = this.state.propkeys;
|
||||
|
||||
propvalues.splice(i, 1);
|
||||
propkeys.splice(i, 1);
|
||||
this.setPropKeys(propkeys);
|
||||
this.setPropValues(propvalues);
|
||||
this.handleRefresh(i);
|
||||
this.setState({
|
||||
propvalues: propvalues,
|
||||
propkeys: propkeys,
|
||||
});
|
||||
}
|
||||
|
||||
renderKeyRows() {
|
||||
var context = this;
|
||||
|
||||
return this.state.propkeys.map(function (o, i) {
|
||||
return (
|
||||
<tr key={"item-" + i}>
|
||||
<td>
|
||||
<input
|
||||
className="properties-table-keyvalues"
|
||||
type="text"
|
||||
value={o}
|
||||
id={o + i}
|
||||
onChange={context.handleKeyChanged.bind(context, i)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
}
|
||||
renderValueRows() {
|
||||
var context = this;
|
||||
|
||||
return this.state.propvalues.map(function (o, i) {
|
||||
return (
|
||||
<tr key={"item-" + i}>
|
||||
<td>
|
||||
<input
|
||||
className="properties-table-keyvalues"
|
||||
type="text"
|
||||
value={o}
|
||||
onChange={context.handleValueChanged.bind(context, i)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
}
|
||||
renderDelete() {
|
||||
var context = this;
|
||||
|
||||
return this.state.propvalues.map(function (o, i) {
|
||||
return (
|
||||
<tr key={"item-" + i}>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
onClick={context.handleItemDeleted.bind(context, i)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<table className="">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{this.renderKeyRows()}</td>
|
||||
<td>{this.renderValueRows()}</td>
|
||||
<td>{this.renderDelete()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<form>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
className="properties-table"
|
||||
type="text"
|
||||
value={this.state.message}
|
||||
onChange={this.updateMessageKey.bind(this)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="properties-table"
|
||||
type="text"
|
||||
value={this.state.message2}
|
||||
onChange={this.updateMessageValue.bind(this)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
onClick={this.handleClick.bind(this)}
|
||||
>
|
||||
Add Item
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</form>
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
64
jsx/src/components/DynamicTable/table-select.css
Normal file
64
jsx/src/components/DynamicTable/table-select.css
Normal file
@@ -0,0 +1,64 @@
|
||||
@import url(../../style/root.css);
|
||||
|
||||
.properties-table {
|
||||
width: 95%;
|
||||
position: relative;
|
||||
padding: 5px;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.properties-table-keyvalues {
|
||||
width: 95%;
|
||||
position: relative;
|
||||
padding: 5px;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.properties-table div {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.properties-table-keyvalues .item {
|
||||
padding: 3px;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
border-radius: 1px;
|
||||
font-size: 14px;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
transition: 30ms ease-in all;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border: solid 1px #dfdfdf;
|
||||
}
|
||||
|
||||
.properties-table .item {
|
||||
padding: 3px;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
border-radius: 1px;
|
||||
font-size: 14px;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
transition: 30ms ease-in all;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border: solid 1px #dfdfdf;
|
||||
}
|
||||
|
||||
.properties-table .item.unselected {
|
||||
background-color: #f7f7f7;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.properties-table .item.selected {
|
||||
background-color: orange;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.properties-table .item:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.boxed {
|
||||
border: 1px solid red;
|
||||
}
|
@@ -3,6 +3,19 @@ import { useSelector, useDispatch } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
import GroupSelect from "../GroupSelect/GroupSelect";
|
||||
import DynamicTable from "../DynamicTable/DynamicTable";
|
||||
|
||||
function hasDuplicates(array) {
|
||||
var valuesSoFar = Object.create(null);
|
||||
for (var i = 0; i < array.length; ++i) {
|
||||
var value = array[i];
|
||||
if (value in valuesSoFar) {
|
||||
return true;
|
||||
}
|
||||
valuesSoFar[value] = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const GroupEdit = (props) => {
|
||||
var [selected, setSelected] = useState([]),
|
||||
@@ -23,6 +36,7 @@ const GroupEdit = (props) => {
|
||||
|
||||
var {
|
||||
addToGroup,
|
||||
updateProp,
|
||||
removeFromGroup,
|
||||
deleteGroup,
|
||||
updateGroups,
|
||||
@@ -37,6 +51,9 @@ const GroupEdit = (props) => {
|
||||
}
|
||||
|
||||
var { group_data } = location.state;
|
||||
var [propobject, setProp] = useState(group_data.properties);
|
||||
var [propkeys, setPropKeys] = useState([]);
|
||||
var [propvalues, setPropValues] = useState([]);
|
||||
|
||||
if (!group_data) return <div></div>;
|
||||
|
||||
@@ -57,6 +74,19 @@ const GroupEdit = (props) => {
|
||||
setChanged(true);
|
||||
}}
|
||||
/>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="alert alert-info">Manage group properties</div>
|
||||
</div>
|
||||
</div>
|
||||
<DynamicTable
|
||||
current_propobject={group_data.properties}
|
||||
setProp={setProp}
|
||||
setPropKeys={setPropKeys}
|
||||
setPropValues={setPropValues}
|
||||
setChanged={setChanged}
|
||||
//Add keys
|
||||
/>
|
||||
<div className="row">
|
||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||
<button id="return" className="btn btn-light">
|
||||
@@ -68,10 +98,10 @@ const GroupEdit = (props) => {
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
// check for changes
|
||||
if (!changed) {
|
||||
history.push("/groups");
|
||||
return;
|
||||
}
|
||||
//if (!changed) {
|
||||
// history.push("/groups");
|
||||
// return;
|
||||
//}
|
||||
|
||||
let new_users = selected.filter(
|
||||
(e) => !group_data.users.includes(e)
|
||||
@@ -79,7 +109,6 @@ const GroupEdit = (props) => {
|
||||
let removed_users = group_data.users.filter(
|
||||
(e) => !selected.includes(e)
|
||||
);
|
||||
|
||||
let promiseQueue = [];
|
||||
if (new_users.length > 0)
|
||||
promiseQueue.push(addToGroup(new_users, group_data.name));
|
||||
@@ -87,18 +116,33 @@ const GroupEdit = (props) => {
|
||||
promiseQueue.push(
|
||||
removeFromGroup(removed_users, group_data.name)
|
||||
);
|
||||
|
||||
if (hasDuplicates(propkeys) == true) {
|
||||
error.textContent = "Duplicate key found!";
|
||||
error.style.color = "red";
|
||||
} else {
|
||||
error.textContent = "";
|
||||
propkeys.forEach((key, i) => (propobject[key] = propvalues[i]));
|
||||
}
|
||||
if (
|
||||
propobject != group_data.properties &&
|
||||
hasDuplicates(propkeys) == false
|
||||
) {
|
||||
promiseQueue.push(updateProp(propobject, group_data.name));
|
||||
}
|
||||
Promise.all(promiseQueue)
|
||||
.then(() => {
|
||||
updateGroups(0, limit)
|
||||
.then((data) => dispatchPageUpdate(data, 0))
|
||||
.then(() => history.push("/groups"));
|
||||
updateGroups(0, limit).then((data) =>
|
||||
dispatchPageUpdate(data, 0)
|
||||
);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<div>
|
||||
<span id="error"></span>
|
||||
</div>
|
||||
<button
|
||||
id="delete-group"
|
||||
className="btn btn-danger"
|
||||
|
@@ -19,6 +19,8 @@ const withAPI = withProps(() => ({
|
||||
names.map((e) => jhapiRequest("/users/" + e + "/server", "DELETE")),
|
||||
addToGroup: (users, groupname) =>
|
||||
jhapiRequest("/groups/" + groupname + "/users", "POST", { users }),
|
||||
updateProp: (propobject, groupname) =>
|
||||
jhapiRequest("/groups/" + groupname + "/properties", "PUT", propobject),
|
||||
removeFromGroup: (users, groupname) =>
|
||||
jhapiRequest("/groups/" + groupname + "/users", "DELETE", { users }),
|
||||
createGroup: (groupName) => jhapiRequest("/groups/" + groupName, "POST"),
|
||||
|
@@ -181,8 +181,25 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
|
||||
class GroupPropertiesAPIHandler(_GroupAPIHandler):
|
||||
"""Modify a group's properties"""
|
||||
|
||||
@needs_scope('groups')
|
||||
def put(self, group_name):
|
||||
group = self.find_group(group_name)
|
||||
data = self.get_json_body()
|
||||
# self._check_group_model(data)
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPError(400, "Must specify properties")
|
||||
self.log.info("Updating properties of group %s", group_name)
|
||||
group.properties = data
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/groups", GroupListAPIHandler),
|
||||
(r"/api/groups/([^/]+)", GroupAPIHandler),
|
||||
(r"/api/groups/([^/]+)/users", GroupUsersAPIHandler),
|
||||
(r"/api/groups/([^/]+)/properties", GroupPropertiesAPIHandler),
|
||||
]
|
||||
|
@@ -222,6 +222,8 @@ class Group(Base):
|
||||
__tablename__ = 'groups'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode(255), unique=True)
|
||||
# The properties column contains key:value pairs that represent group settings and their values. Example: {"ram": 8, "cpu": 4}
|
||||
properties = Column(JSONDict, default={})
|
||||
users = relationship('User', secondary='user_group_map', backref='groups')
|
||||
|
||||
def __repr__(self):
|
||||
|
@@ -1613,8 +1613,20 @@ async def test_groups_list(app):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply == [
|
||||
{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []},
|
||||
{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []},
|
||||
{
|
||||
'kind': 'group',
|
||||
'name': 'alphaflight',
|
||||
'users': [],
|
||||
'roles': [],
|
||||
'properties': {},
|
||||
},
|
||||
{
|
||||
'kind': 'group',
|
||||
'name': 'betaflight',
|
||||
'users': [],
|
||||
'roles': [],
|
||||
'properties': {},
|
||||
},
|
||||
]
|
||||
|
||||
# Test offset for pagination
|
||||
@@ -1622,7 +1634,15 @@ async def test_groups_list(app):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert r.status_code == 200
|
||||
assert reply == [{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []}]
|
||||
assert reply == [
|
||||
{
|
||||
'kind': 'group',
|
||||
'name': 'betaflight',
|
||||
'users': [],
|
||||
'roles': [],
|
||||
'properties': {},
|
||||
}
|
||||
]
|
||||
|
||||
r = await api_request(app, "groups?offset=10")
|
||||
r.raise_for_status()
|
||||
@@ -1634,7 +1654,15 @@ async def test_groups_list(app):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert r.status_code == 200
|
||||
assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}]
|
||||
assert reply == [
|
||||
{
|
||||
'kind': 'group',
|
||||
'name': 'alphaflight',
|
||||
'users': [],
|
||||
'roles': [],
|
||||
'properties': {},
|
||||
}
|
||||
]
|
||||
|
||||
# 0 is rounded up to 1
|
||||
r = await api_request(app, "groups?limit=0")
|
||||
@@ -1683,6 +1711,7 @@ async def test_group_get(app):
|
||||
'name': 'alphaflight',
|
||||
'users': ['sasquatch'],
|
||||
'roles': [],
|
||||
'properties': {},
|
||||
}
|
||||
|
||||
|
||||
@@ -1770,6 +1799,32 @@ async def test_group_add_delete_users(app):
|
||||
assert sorted(u.name for u in group.users) == sorted(names[2:])
|
||||
|
||||
|
||||
@mark.group
|
||||
async def test_group_add_properties(app):
|
||||
db = app.db
|
||||
# must specify users
|
||||
r = await api_request(app, 'groups/alphaflight/properties', method='put', data='{}')
|
||||
assert r.status_code == 200
|
||||
|
||||
properties_object = {'cpu': "8", 'ram': "4", 'image': "testimage"}
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'groups/alphaflight/properties',
|
||||
method='put',
|
||||
data=json.dumps(properties_object),
|
||||
)
|
||||
r.raise_for_status()
|
||||
group = orm.Group.find(db, name='alphaflight')
|
||||
|
||||
assert sorted([k for k in group.properties]) == sorted(
|
||||
[k for k in properties_object]
|
||||
)
|
||||
assert sorted([group.properties[k] for k in group.properties]) == sorted(
|
||||
[properties_object[k] for k in properties_object]
|
||||
)
|
||||
|
||||
|
||||
# -----------------
|
||||
# Service API tests
|
||||
# -----------------
|
||||
|
Reference in New Issue
Block a user