mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-09 19:13:03 +00:00
Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
24506888ea | ||
![]() |
457ad3ec85 | ||
![]() |
75e8274a7b | ||
![]() |
2a3ceff29f | ||
![]() |
5e29605341 | ||
![]() |
34b6bc3a3f | ||
![]() |
7a48da1916 | ||
![]() |
5eaf59dd72 | ||
![]() |
73a33ed5fc | ||
![]() |
0b9ae96a96 | ||
![]() |
2c9653bc0d | ||
![]() |
71e86f3064 | ||
![]() |
8a1110f2c0 | ||
![]() |
bb52351a6e | ||
![]() |
87c745d3bf | ||
![]() |
374c6c848b | ||
![]() |
af31ee8c94 | ||
![]() |
26a9883b93 | ||
![]() |
bda3e0c931 | ||
![]() |
f3d17eb77e | ||
![]() |
5f92cfcc0e | ||
![]() |
b55eaae51f | ||
![]() |
c9e6d6afa3 | ||
![]() |
2f1d340c42 | ||
![]() |
2ba99656c1 | ||
![]() |
635f63c1cd | ||
![]() |
b9b49ff306 | ||
![]() |
5640a1506e | ||
![]() |
4767cfa4e9 | ||
![]() |
309d687c26 | ||
![]() |
df25c09962 | ||
![]() |
09d0909878 | ||
![]() |
72db4624e0 | ||
![]() |
e9eca22e3b | ||
![]() |
33d4f382d5 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -145,7 +145,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub
|
- name: Build and push jupyterhub
|
||||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -166,7 +166,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-onbuild
|
- name: Build and push jupyterhub-onbuild
|
||||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-demo
|
- name: Build and push jupyterhub-demo
|
||||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub/singleuser
|
- name: Build and push jupyterhub/singleuser
|
||||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||||
|
17
.github/workflows/test.yml
vendored
17
.github/workflows/test.yml
vendored
@@ -71,6 +71,8 @@ jobs:
|
|||||||
# NOTE: Since only the value of these parameters are presented in the
|
# NOTE: Since only the value of these parameters are presented in the
|
||||||
# GitHub UI when the workflow run, we avoid using true/false as
|
# GitHub UI when the workflow run, we avoid using true/false as
|
||||||
# values by instead duplicating the name to signal true.
|
# values by instead duplicating the name to signal true.
|
||||||
|
# Python versions available at:
|
||||||
|
# https://github.com/actions/python-versions/blob/HEAD/versions-manifest.json
|
||||||
include:
|
include:
|
||||||
- python: "3.7"
|
- python: "3.7"
|
||||||
oldest_dependencies: oldest_dependencies
|
oldest_dependencies: oldest_dependencies
|
||||||
@@ -85,10 +87,7 @@ jobs:
|
|||||||
subdomain: subdomain
|
subdomain: subdomain
|
||||||
- python: "3.10"
|
- python: "3.10"
|
||||||
ssl: ssl
|
ssl: ssl
|
||||||
# can't test 3.11.0-beta.4 until a greenlet release
|
- python: "3.11.0-rc.1"
|
||||||
# greenlet is a dependency of sqlalchemy on linux
|
|
||||||
# see https://github.com/gevent/gevent/issues/1867
|
|
||||||
# - python: "3.11.0-beta.4"
|
|
||||||
- python: "3.10"
|
- python: "3.10"
|
||||||
main_dependencies: main_dependencies
|
main_dependencies: main_dependencies
|
||||||
|
|
||||||
@@ -136,9 +135,19 @@ jobs:
|
|||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python }}"
|
python-version: "${{ matrix.python }}"
|
||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
|
if [[ "${{ matrix.python }}" == "3.11"* ]]; then
|
||||||
|
# greenlet is not actually required,
|
||||||
|
# but is an install dependency of sqlalchemy.
|
||||||
|
# It does not yet install on 3.11
|
||||||
|
# see: see https://github.com/gevent/gevent/issues/1867
|
||||||
|
pip install ./ci/mock-greenlet
|
||||||
|
fi
|
||||||
|
|
||||||
pip install --upgrade . -r dev-requirements.txt
|
pip install --upgrade . -r dev-requirements.txt
|
||||||
|
|
||||||
if [ "${{ matrix.oldest_dependencies }}" != "" ]; then
|
if [ "${{ matrix.oldest_dependencies }}" != "" ]; then
|
||||||
|
@@ -47,6 +47,6 @@ repos:
|
|||||||
|
|
||||||
# Linting: Python code (see the file .flake8)
|
# Linting: Python code (see the file .flake8)
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: "5.0.2"
|
rev: "5.0.4"
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
|
3
ci/mock-greenlet/greenlet.py
Normal file
3
ci/mock-greenlet/greenlet.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__version__ = "22.0.0.dev0"
|
||||||
|
|
||||||
|
raise ImportError("Don't actually have greenlet")
|
13
ci/mock-greenlet/pyproject.toml
Normal file
13
ci/mock-greenlet/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "greenlet"
|
||||||
|
description = 'Mock greenlet to allow install on 3.11'
|
||||||
|
requires-python = ">=3.7"
|
||||||
|
dynamic = ["version"]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "greenlet.py"
|
@@ -6,7 +6,7 @@ info:
|
|||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
version: 3.0.0b1
|
version: 3.0.0
|
||||||
servers:
|
servers:
|
||||||
- url: /hub/api
|
- url: /hub/api
|
||||||
security:
|
security:
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -38,6 +38,15 @@ A Service may have the following properties:
|
|||||||
- `display: bool (default - True)` - When set to true, display a link to the
|
- `display: bool (default - True)` - When set to true, display a link to the
|
||||||
service's URL under the 'Services' dropdown in user's hub home page.
|
service's URL under the 'Services' dropdown in user's hub home page.
|
||||||
|
|
||||||
|
- `oauth_no_confirm: bool (default - False)` - When set to true,
|
||||||
|
skip the OAuth confirmation page when users access this service.
|
||||||
|
|
||||||
|
By default, when users authenticate with a service using JupyterHub,
|
||||||
|
they are prompted to confirm that they want to grant that service
|
||||||
|
access to their credentials.
|
||||||
|
Skipping the confirmation page is useful for admin-managed services that are considered part of the Hub
|
||||||
|
and shouldn't need extra prompts for login.
|
||||||
|
|
||||||
If a service is also to be managed by the Hub, it has a few extra options:
|
If a service is also to be managed by the Hub, it has a few extra options:
|
||||||
|
|
||||||
- `command: (str/Popen list)` - Command for JupyterHub to spawn the service. - Only use this if the service should be a subprocess. - If command is not specified, the Service is assumed to be managed
|
- `command: (str/Popen list)` - Command for JupyterHub to spawn the service. - Only use this if the service should be a subprocess. - If command is not specified, the Service is assumed to be managed
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { compose } from "recompose";
|
import { compose } from "recompose";
|
||||||
import { initialState, reducers } from "./Store";
|
import { initialState, reducers } from "./Store";
|
||||||
import { jhapiRequest } from "./util/jhapiUtil";
|
|
||||||
import withAPI from "./util/withAPI";
|
import withAPI from "./util/withAPI";
|
||||||
import { HashRouter, Switch, Route } from "react-router-dom";
|
import { HashRouter, Switch, Route } from "react-router-dom";
|
||||||
|
|
||||||
@@ -20,24 +19,6 @@ import "./style/root.css";
|
|||||||
const store = createStore(reducers, initialState);
|
const store = createStore(reducers, initialState);
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
useEffect(() => {
|
|
||||||
let { limit, user_page, groups_page } = initialState;
|
|
||||||
let api = withAPI()().props;
|
|
||||||
api
|
|
||||||
.updateUsers(user_page * limit, limit)
|
|
||||||
.then((data) =>
|
|
||||||
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
|
|
||||||
)
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
|
|
||||||
api
|
|
||||||
.updateGroups(groups_page * limit, limit)
|
|
||||||
.then((data) =>
|
|
||||||
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
|
|
||||||
)
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="resets">
|
<div className="resets">
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
@@ -1,23 +1,48 @@
|
|||||||
export const initialState = {
|
export const initialState = {
|
||||||
user_data: undefined,
|
user_data: undefined,
|
||||||
user_page: 0,
|
user_page: { offset: 0, limit: window.api_page_limit || 100 },
|
||||||
name_filter: "",
|
name_filter: "",
|
||||||
groups_data: undefined,
|
groups_data: undefined,
|
||||||
groups_page: 0,
|
groups_page: { offset: 0, limit: window.api_page_limit || 100 },
|
||||||
limit: window.api_page_limit,
|
limit: window.api_page_limit || 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const reducers = (state = initialState, action) => {
|
export const reducers = (state = initialState, action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
// Updates the client user model data and stores the page
|
// Updates the client user model data and stores the page
|
||||||
|
case "USER_OFFSET":
|
||||||
|
return Object.assign({}, state, {
|
||||||
|
user_page: Object.assign({}, state.user_page, {
|
||||||
|
offset: action.value.offset,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
case "USER_NAME_FILTER":
|
||||||
|
// set offset to 0 if name filter changed,
|
||||||
|
// otherwise leave it alone
|
||||||
|
const newOffset =
|
||||||
|
action.value.name_filter !== state.name_filter ? 0 : state.name_filter;
|
||||||
|
return Object.assign({}, state, {
|
||||||
|
user_page: Object.assign({}, state.user_page, {
|
||||||
|
offset: newOffset,
|
||||||
|
}),
|
||||||
|
name_filter: action.value.name_filter,
|
||||||
|
});
|
||||||
|
|
||||||
case "USER_PAGE":
|
case "USER_PAGE":
|
||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
user_page: action.value.page,
|
user_page: action.value.page,
|
||||||
user_data: action.value.data,
|
user_data: action.value.data,
|
||||||
name_filter: action.value.name_filter || "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Updates the client group model data and stores the page
|
// Updates the client group user model data and stores the page
|
||||||
|
case "GROUPS_OFFSET":
|
||||||
|
return Object.assign({}, state, {
|
||||||
|
groups_page: Object.assign({}, state.groups_page, {
|
||||||
|
offset: action.value.offset,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
case "GROUPS_PAGE":
|
case "GROUPS_PAGE":
|
||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
groups_page: action.value.page,
|
groups_page: action.value.page,
|
||||||
|
@@ -125,38 +125,12 @@ const EditUser = (props) => {
|
|||||||
if (updatedUsername == "" && admin == has_admin) {
|
if (updatedUsername == "" && admin == has_admin) {
|
||||||
noChangeEvent();
|
noChangeEvent();
|
||||||
return;
|
return;
|
||||||
} else if (updatedUsername != "") {
|
|
||||||
if (
|
|
||||||
updatedUsername.length > 2 &&
|
|
||||||
/[!@#$%^&*(),.?":{}|<>]/g.test(updatedUsername) == false
|
|
||||||
) {
|
|
||||||
editUser(
|
|
||||||
username,
|
|
||||||
updatedUsername != "" ? updatedUsername : username,
|
|
||||||
admin
|
|
||||||
)
|
|
||||||
.then((data) => {
|
|
||||||
data.status < 300
|
|
||||||
? updateUsers(0, limit)
|
|
||||||
.then((data) => dispatchPageChange(data, 0))
|
|
||||||
.then(() => history.push("/"))
|
|
||||||
.catch(() =>
|
|
||||||
setErrorAlert(
|
|
||||||
`Could not update users list.`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: setErrorAlert(`Failed to edit user.`);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setErrorAlert(`Failed to edit user.`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setErrorAlert(
|
|
||||||
`Failed to edit user. Make sure the username does not contain special characters.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
editUser(username, username, admin)
|
editUser(
|
||||||
|
username,
|
||||||
|
updatedUsername != "" ? updatedUsername : username,
|
||||||
|
admin
|
||||||
|
)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
data.status < 300
|
data.status < 300
|
||||||
? updateUsers(0, limit)
|
? updateUsers(0, limit)
|
||||||
|
@@ -122,7 +122,6 @@ const GroupEdit = (props) => {
|
|||||||
: setErrorAlert(`Failed to edit group.`);
|
: setErrorAlert(`Failed to edit group.`);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.log("outer");
|
|
||||||
setErrorAlert(`Failed to edit group.`);
|
setErrorAlert(`Failed to edit group.`);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
@@ -6,23 +6,26 @@ import { Link } from "react-router-dom";
|
|||||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||||
|
|
||||||
const Groups = (props) => {
|
const Groups = (props) => {
|
||||||
var user_data = useSelector((state) => state.user_data),
|
var groups_data = useSelector((state) => state.groups_data),
|
||||||
groups_data = useSelector((state) => state.groups_data),
|
|
||||||
groups_page = useSelector((state) => state.groups_page),
|
groups_page = useSelector((state) => state.groups_page),
|
||||||
limit = useSelector((state) => state.limit),
|
dispatch = useDispatch();
|
||||||
dispatch = useDispatch(),
|
|
||||||
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
|
||||||
|
|
||||||
page = isNaN(page) ? 0 : page;
|
var offset = groups_page ? groups_page.offset : 0;
|
||||||
var slice = [page * limit, limit];
|
|
||||||
|
const setOffset = (offset) => {
|
||||||
|
dispatch({
|
||||||
|
type: "GROUPS_OFFSET",
|
||||||
|
value: {
|
||||||
|
offset: offset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
var limit = groups_page ? groups_page.limit : window.api_page_limit;
|
||||||
|
var total = groups_page ? groups_page.total : undefined;
|
||||||
|
|
||||||
var { updateGroups, history } = props;
|
var { updateGroups, history } = props;
|
||||||
|
|
||||||
if (!groups_data || !user_data) {
|
const dispatchPageUpdate = (data, page) => {
|
||||||
return <div data-testid="no-show"></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatchPageChange = (data, page) => {
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "GROUPS_PAGE",
|
type: "GROUPS_PAGE",
|
||||||
value: {
|
value: {
|
||||||
@@ -32,10 +35,14 @@ const Groups = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (groups_page != page) {
|
useEffect(() => {
|
||||||
updateGroups(...slice).then((data) => {
|
updateGroups(offset, limit).then((data) =>
|
||||||
dispatchPageChange(data, page);
|
dispatchPageUpdate(data.items, data._pagination)
|
||||||
});
|
);
|
||||||
|
}, [offset, limit]);
|
||||||
|
|
||||||
|
if (!groups_data || !groups_page) {
|
||||||
|
return <div data-testid="no-show"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +66,6 @@ const Groups = (props) => {
|
|||||||
pathname: "/group-edit",
|
pathname: "/group-edit",
|
||||||
state: {
|
state: {
|
||||||
group_data: e,
|
group_data: e,
|
||||||
user_data: user_data,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -74,11 +80,12 @@ const Groups = (props) => {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
endpoint="/groups"
|
offset={offset}
|
||||||
page={page}
|
|
||||||
limit={limit}
|
limit={limit}
|
||||||
numOffset={slice[0]}
|
visible={groups_data.length}
|
||||||
numElements={groups_data.length}
|
total={total}
|
||||||
|
next={() => setOffset(offset + limit)}
|
||||||
|
prev={() => setOffset(offset >= limit ? offset - limit : 0)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-footer">
|
<div className="panel-footer">
|
||||||
@@ -102,8 +109,6 @@ const Groups = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Groups.propTypes = {
|
Groups.propTypes = {
|
||||||
user_data: PropTypes.array,
|
|
||||||
groups_data: PropTypes.array,
|
|
||||||
updateUsers: PropTypes.func,
|
updateUsers: PropTypes.func,
|
||||||
updateGroups: PropTypes.func,
|
updateGroups: PropTypes.func,
|
||||||
history: PropTypes.shape({
|
history: PropTypes.shape({
|
||||||
|
@@ -1,53 +1,72 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
import { act } from "react-dom/test-utils";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router-dom";
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
|
import { initialState, reducers } from "../../Store";
|
||||||
import Groups from "./Groups";
|
import Groups from "./Groups";
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
useDispatch: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
var mockAsync = () =>
|
var mockAsync = () =>
|
||||||
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
||||||
|
|
||||||
var groupsJsx = (callbackSpy) => (
|
var groupsJsx = (callbackSpy) => (
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(mockReducers, mockAppState())}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
|
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
var mockAppState = () => ({
|
var mockReducers = jest.fn((state, action) => {
|
||||||
user_data: JSON.parse(
|
if (action.type === "GROUPS_PAGE" && !action.value.data) {
|
||||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
// no-op from mock, don't update state
|
||||||
),
|
return state;
|
||||||
groups_data: JSON.parse(
|
}
|
||||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
state = reducers(state, action);
|
||||||
),
|
// mocked useSelector seems to cause a problem
|
||||||
limit: 10,
|
// this should get the right state back?
|
||||||
|
// not sure
|
||||||
|
// useSelector.mockImplementation((callback) => callback(state);
|
||||||
|
return state;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var mockAppState = () =>
|
||||||
|
Object.assign({}, initialState, {
|
||||||
|
groups_data: [
|
||||||
|
{ kind: "group", name: "testgroup", users: [] },
|
||||||
|
{ kind: "group", name: "testgroup2", users: ["foo", "bar"] },
|
||||||
|
],
|
||||||
|
groups_page: {
|
||||||
|
offset: 0,
|
||||||
|
limit: 2,
|
||||||
|
total: 4,
|
||||||
|
next: {
|
||||||
|
offset: 2,
|
||||||
|
limit: 2,
|
||||||
|
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
useDispatch.mockImplementation(() => {
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSelector.mockClear();
|
useSelector.mockClear();
|
||||||
|
mockReducers.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
@@ -88,3 +107,30 @@ test("Renders nothing if required data is not available", async () => {
|
|||||||
let noShow = screen.getByTestId("no-show");
|
let noShow = screen.getByTestId("no-show");
|
||||||
expect(noShow).toBeVisible();
|
expect(noShow).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupsJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toBeCalledWith(0, 2);
|
||||||
|
|
||||||
|
var lastState =
|
||||||
|
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||||
|
expect(lastState.groups_page.offset).toEqual(0);
|
||||||
|
expect(lastState.groups_page.limit).toEqual(2);
|
||||||
|
|
||||||
|
let next = screen.getByTestId("paginate-next");
|
||||||
|
fireEvent.click(next);
|
||||||
|
|
||||||
|
lastState =
|
||||||
|
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||||
|
expect(lastState.groups_page.offset).toEqual(2);
|
||||||
|
expect(lastState.groups_page.limit).toEqual(2);
|
||||||
|
|
||||||
|
// FIXME: mocked useSelector, state seem to prevent updateGroups from being called
|
||||||
|
// making the test environment not representative
|
||||||
|
// expect(callbackSpy).toHaveBeenCalledWith(2, 2);
|
||||||
|
});
|
||||||
|
@@ -1,33 +1,40 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import "./pagination-footer.css";
|
import "./pagination-footer.css";
|
||||||
|
|
||||||
const PaginationFooter = (props) => {
|
const PaginationFooter = (props) => {
|
||||||
let { endpoint, page, limit, numOffset, numElements } = props;
|
let { offset, limit, visible, total, next, prev } = props;
|
||||||
return (
|
return (
|
||||||
<div className="pagination-footer">
|
<div className="pagination-footer">
|
||||||
<p>
|
<p>
|
||||||
Displaying {numOffset}-{numOffset + numElements}
|
Displaying {offset}-{offset + visible}
|
||||||
<br></br>
|
<br></br>
|
||||||
<br></br>
|
<br></br>
|
||||||
{page >= 1 ? (
|
{offset >= 1 ? (
|
||||||
<button className="btn btn-sm btn-light spaced">
|
<button className="btn btn-sm btn-light spaced">
|
||||||
<Link to={`${endpoint}?page=${page - 1}`}>
|
<span
|
||||||
<span className="active-pagination">Previous</span>
|
className="active-pagination"
|
||||||
</Link>
|
data-testid="paginate-prev"
|
||||||
|
onClick={prev}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-sm btn-light spaced">
|
<button className="btn btn-sm btn-light spaced">
|
||||||
<span className="inactive-pagination">Previous</span>
|
<span className="inactive-pagination">Previous</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{numElements >= limit ? (
|
{offset + visible < total ? (
|
||||||
<button className="btn btn-sm btn-light spaced">
|
<button className="btn btn-sm btn-light spaced">
|
||||||
<Link to={`${endpoint}?page=${page + 1}`}>
|
<span
|
||||||
<span className="active-pagination">Next</span>
|
className="active-pagination"
|
||||||
</Link>
|
data-testid="paginate-next"
|
||||||
|
onClick={next}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-sm btn-light spaced">
|
<button className="btn btn-sm btn-light spaced">
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import regeneratorRuntime from "regenerator-runtime";
|
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { debounce } from "lodash";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -50,16 +50,15 @@ const ServerDashboard = (props) => {
|
|||||||
var [errorAlert, setErrorAlert] = useState(null);
|
var [errorAlert, setErrorAlert] = useState(null);
|
||||||
var [sortMethod, setSortMethod] = useState(null);
|
var [sortMethod, setSortMethod] = useState(null);
|
||||||
var [disabledButtons, setDisabledButtons] = useState({});
|
var [disabledButtons, setDisabledButtons] = useState({});
|
||||||
const [collapseStates, setCollapseStates] = useState({});
|
var [collapseStates, setCollapseStates] = useState({});
|
||||||
|
|
||||||
var user_data = useSelector((state) => state.user_data),
|
var user_data = useSelector((state) => state.user_data),
|
||||||
user_page = useSelector((state) => state.user_page),
|
user_page = useSelector((state) => state.user_page),
|
||||||
limit = useSelector((state) => state.limit),
|
name_filter = useSelector((state) => state.name_filter);
|
||||||
name_filter = useSelector((state) => state.name_filter),
|
|
||||||
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
|
||||||
|
|
||||||
page = isNaN(page) ? 0 : page;
|
var offset = user_page ? user_page.offset : 0;
|
||||||
var slice = [page * limit, limit, name_filter];
|
var limit = user_page ? user_page.limit : window.api_page_limit;
|
||||||
|
var total = user_page ? user_page.total : undefined;
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -73,33 +72,48 @@ const ServerDashboard = (props) => {
|
|||||||
history,
|
history,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
var dispatchPageUpdate = (data, page, name_filter) => {
|
const dispatchPageUpdate = (data, page) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "USER_PAGE",
|
type: "USER_PAGE",
|
||||||
value: {
|
value: {
|
||||||
data: data,
|
data: data,
|
||||||
page: page,
|
page: page,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOffset = (newOffset) => {
|
||||||
|
dispatch({
|
||||||
|
type: "USER_OFFSET",
|
||||||
|
value: {
|
||||||
|
offset: newOffset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNameFilter = (name_filter) => {
|
||||||
|
dispatch({
|
||||||
|
type: "USER_NAME_FILTER",
|
||||||
|
value: {
|
||||||
name_filter: name_filter,
|
name_filter: name_filter,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user_data) {
|
useEffect(() => {
|
||||||
|
updateUsers(offset, limit, name_filter)
|
||||||
|
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
||||||
|
.catch((err) => setErrorAlert("Failed to update user list."));
|
||||||
|
}, [offset, limit, name_filter]);
|
||||||
|
|
||||||
|
if (!user_data || !user_page) {
|
||||||
return <div data-testid="no-show"></div>;
|
return <div data-testid="no-show"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page != user_page) {
|
var slice = [offset, limit, name_filter];
|
||||||
updateUsers(...slice).then((data) =>
|
|
||||||
dispatchPageUpdate(data, page, name_filter)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var debounce = require("lodash.debounce");
|
|
||||||
const handleSearch = debounce(async (event) => {
|
const handleSearch = debounce(async (event) => {
|
||||||
// setNameFilter(event.target.value);
|
setNameFilter(event.target.value);
|
||||||
updateUsers(page * limit, limit, event.target.value).then((data) =>
|
|
||||||
dispatchPageUpdate(data, page, name_filter)
|
|
||||||
);
|
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
if (sortMethod != null) {
|
if (sortMethod != null) {
|
||||||
@@ -119,7 +133,11 @@ const ServerDashboard = (props) => {
|
|||||||
if (res.status < 300) {
|
if (res.status < 300) {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page, name_filter);
|
dispatchPageUpdate(
|
||||||
|
data.items,
|
||||||
|
data._pagination,
|
||||||
|
name_filter
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setIsDisabled(false);
|
setIsDisabled(false);
|
||||||
@@ -155,7 +173,11 @@ const ServerDashboard = (props) => {
|
|||||||
if (res.status < 300) {
|
if (res.status < 300) {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page, name_filter);
|
dispatchPageUpdate(
|
||||||
|
data.items,
|
||||||
|
data._pagination,
|
||||||
|
name_filter
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setErrorAlert(`Failed to update users list.`);
|
setErrorAlert(`Failed to update users list.`);
|
||||||
@@ -457,7 +479,11 @@ const ServerDashboard = (props) => {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page, name_filter);
|
dispatchPageUpdate(
|
||||||
|
data.items,
|
||||||
|
data._pagination,
|
||||||
|
name_filter
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setErrorAlert(`Failed to update users list.`)
|
setErrorAlert(`Failed to update users list.`)
|
||||||
@@ -493,7 +519,11 @@ const ServerDashboard = (props) => {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page, name_filter);
|
dispatchPageUpdate(
|
||||||
|
data.items,
|
||||||
|
data._pagination,
|
||||||
|
name_filter
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setErrorAlert(`Failed to update users list.`)
|
setErrorAlert(`Failed to update users list.`)
|
||||||
@@ -521,11 +551,12 @@ const ServerDashboard = (props) => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
endpoint="/"
|
offset={offset}
|
||||||
page={page}
|
|
||||||
limit={limit}
|
limit={limit}
|
||||||
numOffset={slice[0]}
|
visible={user_data.length}
|
||||||
numElements={user_data.length}
|
total={total}
|
||||||
|
next={() => setOffset(offset + limit)}
|
||||||
|
prev={() => setOffset(offset - limit)}
|
||||||
/>
|
/>
|
||||||
<br></br>
|
<br></br>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -10,6 +10,7 @@ import { createStore } from "redux";
|
|||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
import ServerDashboard from "./ServerDashboard";
|
import ServerDashboard from "./ServerDashboard";
|
||||||
|
import { initialState, reducers } from "../../Store";
|
||||||
import * as sinon from "sinon";
|
import * as sinon from "sinon";
|
||||||
|
|
||||||
let clock;
|
let clock;
|
||||||
@@ -20,7 +21,7 @@ jest.mock("react-redux", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
var serverDashboardJsx = (spy) => (
|
var serverDashboardJsx = (spy) => (
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(mockReducers, mockAppState())}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Switch>
|
<Switch>
|
||||||
<ServerDashboard
|
<ServerDashboard
|
||||||
@@ -42,10 +43,67 @@ var mockAsync = (data) =>
|
|||||||
var mockAsyncRejection = () =>
|
var mockAsyncRejection = () =>
|
||||||
jest.fn().mockImplementation(() => Promise.reject());
|
jest.fn().mockImplementation(() => Promise.reject());
|
||||||
|
|
||||||
var mockAppState = () => ({
|
var mockAppState = () =>
|
||||||
user_data: JSON.parse(
|
Object.assign({}, initialState, {
|
||||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
user_data: [
|
||||||
),
|
{
|
||||||
|
kind: "user",
|
||||||
|
name: "foo",
|
||||||
|
admin: true,
|
||||||
|
groups: [],
|
||||||
|
server: "/user/foo/",
|
||||||
|
pending: null,
|
||||||
|
created: "2020-12-07T18:46:27.112695Z",
|
||||||
|
last_activity: "2020-12-07T21:00:33.336354Z",
|
||||||
|
servers: {
|
||||||
|
"": {
|
||||||
|
name: "",
|
||||||
|
last_activity: "2020-12-07T20:58:02.437408Z",
|
||||||
|
started: "2020-12-07T20:58:01.508266Z",
|
||||||
|
pending: null,
|
||||||
|
ready: true,
|
||||||
|
state: { pid: 28085 },
|
||||||
|
url: "/user/foo/",
|
||||||
|
user_options: {},
|
||||||
|
progress_url: "/hub/api/users/foo/server/progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "user",
|
||||||
|
name: "bar",
|
||||||
|
admin: false,
|
||||||
|
groups: [],
|
||||||
|
server: null,
|
||||||
|
pending: null,
|
||||||
|
created: "2020-12-07T18:46:27.115528Z",
|
||||||
|
last_activity: "2020-12-07T20:43:51.013613Z",
|
||||||
|
servers: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
user_page: {
|
||||||
|
offset: 0,
|
||||||
|
limit: 2,
|
||||||
|
total: 4,
|
||||||
|
next: {
|
||||||
|
offset: 2,
|
||||||
|
limit: 2,
|
||||||
|
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var mockReducers = jest.fn((state, action) => {
|
||||||
|
if (action.type === "USER_PAGE" && !action.value.data) {
|
||||||
|
// no-op from mock, don't update state
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
state = reducers(state, action);
|
||||||
|
// mocked useSelector seems to cause a problem
|
||||||
|
// this should get the right state back?
|
||||||
|
// not sure
|
||||||
|
// useSelector.mockImplementation((callback) => callback(state);
|
||||||
|
return state;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -57,6 +115,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSelector.mockClear();
|
useSelector.mockClear();
|
||||||
|
mockReducers.mockClear();
|
||||||
clock.restore();
|
clock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -498,11 +557,22 @@ test("Shows a UI error dialogue when stop user server returns an improper status
|
|||||||
test("Search for user calls updateUsers with name filter", async () => {
|
test("Search for user calls updateUsers with name filter", async () => {
|
||||||
let spy = mockAsync();
|
let spy = mockAsync();
|
||||||
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
|
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve({
|
||||||
|
items: [],
|
||||||
|
_pagination: {
|
||||||
|
offset: offset,
|
||||||
|
limit: limit,
|
||||||
|
total: offset + limit * 2,
|
||||||
|
next: {
|
||||||
|
offset: offset + limit,
|
||||||
|
limit: limit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(
|
render(
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(mockReducers, mockAppState())}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Switch>
|
<Switch>
|
||||||
<ServerDashboard
|
<ServerDashboard
|
||||||
@@ -521,15 +591,56 @@ test("Search for user calls updateUsers with name filter", async () => {
|
|||||||
|
|
||||||
let search = screen.getByLabelText("user-search");
|
let search = screen.getByLabelText("user-search");
|
||||||
|
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||||
|
|
||||||
userEvent.type(search, "a");
|
userEvent.type(search, "a");
|
||||||
expect(search.value).toEqual("a");
|
expect(search.value).toEqual("a");
|
||||||
clock.tick(400);
|
clock.tick(400);
|
||||||
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
|
expect(mockReducers.mock.calls).toHaveLength(3);
|
||||||
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
var lastState =
|
||||||
|
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||||
|
expect(lastState.name_filter).toEqual("a");
|
||||||
|
// TODO: this should
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||||
userEvent.type(search, "b");
|
userEvent.type(search, "b");
|
||||||
expect(search.value).toEqual("ab");
|
expect(search.value).toEqual("ab");
|
||||||
clock.tick(400);
|
clock.tick(400);
|
||||||
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
|
expect(mockReducers.mock.calls).toHaveLength(4);
|
||||||
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
|
lastState =
|
||||||
|
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||||
|
expect(lastState.name_filter).toEqual("ab");
|
||||||
|
expect(lastState.user_page.offset).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toBeCalledWith(0, 2, "");
|
||||||
|
|
||||||
|
expect(mockReducers.mock.results).toHaveLength(2);
|
||||||
|
lastState =
|
||||||
|
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||||
|
console.log(lastState);
|
||||||
|
expect(lastState.user_page.offset).toEqual(0);
|
||||||
|
expect(lastState.user_page.limit).toEqual(2);
|
||||||
|
|
||||||
|
let next = screen.getByTestId("paginate-next");
|
||||||
|
fireEvent.click(next);
|
||||||
|
clock.tick(400);
|
||||||
|
|
||||||
|
expect(mockReducers.mock.results).toHaveLength(3);
|
||||||
|
var lastState =
|
||||||
|
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||||
|
expect(lastState.user_page.offset).toEqual(2);
|
||||||
|
expect(lastState.user_page.limit).toEqual(2);
|
||||||
|
|
||||||
|
// FIXME: should call updateUsers, does in reality.
|
||||||
|
// tests don't reflect reality due to mocked state/useSelector
|
||||||
|
// unclear how to fix this.
|
||||||
|
// expect(callbackSpy.mock.calls).toHaveLength(2);
|
||||||
|
// expect(callbackSpy).toHaveBeenCalledWith(2, 2, "");
|
||||||
});
|
});
|
||||||
|
@@ -6,6 +6,7 @@ export const jhapiRequest = (endpoint, method, data) => {
|
|||||||
json: true,
|
json: true,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/jupyterhub-pagination+json",
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : null,
|
body: data ? JSON.stringify(data) : null,
|
||||||
});
|
});
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
# version_info updated by running `tbump`
|
# version_info updated by running `tbump`
|
||||||
version_info = (3, 0, 0, "b1", "")
|
version_info = (3, 0, 0, "", "")
|
||||||
|
|
||||||
# pep 440 version: no dot before beta/rc, but before .dev
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
# 0.1.0rc1
|
# 0.1.0rc1
|
||||||
|
@@ -256,6 +256,9 @@ class Authenticator(LoggingConfigurable):
|
|||||||
if not username:
|
if not username:
|
||||||
# empty usernames are not allowed
|
# empty usernames are not allowed
|
||||||
return False
|
return False
|
||||||
|
if username != username.strip():
|
||||||
|
# starting/ending with space is not allowed
|
||||||
|
return False
|
||||||
if not self.username_regex:
|
if not self.username_regex:
|
||||||
return True
|
return True
|
||||||
return bool(self.username_regex.match(username))
|
return bool(self.username_regex.match(username))
|
||||||
|
@@ -145,7 +145,9 @@ class LoginHandler(BaseHandler):
|
|||||||
# parse the arguments dict
|
# parse the arguments dict
|
||||||
data = {}
|
data = {}
|
||||||
for arg in self.request.arguments:
|
for arg in self.request.arguments:
|
||||||
data[arg] = self.get_argument(arg, strip=False)
|
# strip username, but not other fields like passwords,
|
||||||
|
# which should be allowed to start or end with space
|
||||||
|
data[arg] = self.get_argument(arg, strip=arg == "username")
|
||||||
|
|
||||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||||
user = await self.login_user(data)
|
user = await self.login_user(data)
|
||||||
|
@@ -740,9 +740,17 @@ async def test_login_fail(app):
|
|||||||
assert not r.cookies
|
assert not r.cookies
|
||||||
|
|
||||||
|
|
||||||
async def test_login_strip(app):
|
@pytest.mark.parametrize(
|
||||||
"""Test that login form doesn't strip whitespace from passwords"""
|
"form_user, auth_user, form_password",
|
||||||
form_data = {'username': 'spiff', 'password': ' space man '}
|
[
|
||||||
|
("spiff", "spiff", " space man "),
|
||||||
|
(" spiff ", "spiff", " space man "),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_login_strip(app, form_user, auth_user, form_password):
|
||||||
|
"""Test that login form strips space form usernames, but not passwords"""
|
||||||
|
form_data = {"username": form_user, "password": form_password}
|
||||||
|
expected_auth = {"username": auth_user, "password": form_password}
|
||||||
base_url = public_url(app)
|
base_url = public_url(app)
|
||||||
called_with = []
|
called_with = []
|
||||||
|
|
||||||
@@ -754,7 +762,7 @@ async def test_login_strip(app):
|
|||||||
base_url + 'hub/login', data=form_data, allow_redirects=False
|
base_url + 'hub/login', data=form_data, allow_redirects=False
|
||||||
)
|
)
|
||||||
|
|
||||||
assert called_with == [form_data]
|
assert called_with == [expected_auth]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
@@ -29,12 +29,12 @@ async def test_userdict_get(db, attr):
|
|||||||
["isin1", "isin2"],
|
["isin1", "isin2"],
|
||||||
["isin1"],
|
["isin1"],
|
||||||
["notin", "isin1"],
|
["notin", "isin1"],
|
||||||
["new-group", "isin1"],
|
["new-group", "new-group", "isin1"],
|
||||||
[],
|
[],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_sync_groups(app, user, group_names):
|
def test_sync_groups(app, user, group_names):
|
||||||
expected = sorted(group_names)
|
expected = sorted(set(group_names))
|
||||||
db = app.db
|
db = app.db
|
||||||
db.add(orm.Group(name="notin"))
|
db.add(orm.Group(name="notin"))
|
||||||
in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")]
|
in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")]
|
||||||
|
@@ -310,19 +310,19 @@ class User:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# log group changes
|
# log group changes
|
||||||
new_groups = set(group_names).difference(current_groups)
|
added_groups = new_groups.difference(current_groups)
|
||||||
removed_groups = current_groups.difference(group_names)
|
removed_groups = current_groups.difference(group_names)
|
||||||
if new_groups:
|
if added_groups:
|
||||||
self.log.info(f"Adding user {self.name} to group(s): {new_groups}")
|
self.log.info(f"Adding user {self.name} to group(s): {added_groups}")
|
||||||
if removed_groups:
|
if removed_groups:
|
||||||
self.log.info(f"Removing user {self.name} from group(s): {removed_groups}")
|
self.log.info(f"Removing user {self.name} from group(s): {removed_groups}")
|
||||||
|
|
||||||
if group_names:
|
if group_names:
|
||||||
groups = (
|
groups = (
|
||||||
self.db.query(orm.Group).filter(orm.Group.name.in_(group_names)).all()
|
self.db.query(orm.Group).filter(orm.Group.name.in_(new_groups)).all()
|
||||||
)
|
)
|
||||||
existing_groups = {g.name for g in groups}
|
existing_groups = {g.name for g in groups}
|
||||||
for group_name in group_names:
|
for group_name in added_groups:
|
||||||
if group_name not in existing_groups:
|
if group_name not in existing_groups:
|
||||||
# create groups that don't exist yet
|
# create groups that don't exist yet
|
||||||
self.log.info(
|
self.log.info(
|
||||||
@@ -331,9 +331,9 @@ class User:
|
|||||||
group = orm.Group(name=group_name)
|
group = orm.Group(name=group_name)
|
||||||
self.db.add(group)
|
self.db.add(group)
|
||||||
groups.append(group)
|
groups.append(group)
|
||||||
self.groups = groups
|
self.orm_user.groups = groups
|
||||||
else:
|
else:
|
||||||
self.groups = []
|
self.orm_user.groups = []
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
async def save_auth_state(self, auth_state):
|
async def save_auth_state(self, auth_state):
|
||||||
|
@@ -17,7 +17,7 @@ target_version = [
|
|||||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
[tool.tbump.version]
|
[tool.tbump.version]
|
||||||
current = "3.0.0b1"
|
current = "3.0.0"
|
||||||
|
|
||||||
# Example of a semver regexp.
|
# Example of a semver regexp.
|
||||||
# Make sure this matches current_version before
|
# Make sure this matches current_version before
|
||||||
|
@@ -24,9 +24,10 @@
|
|||||||
{% block expiration_options %}
|
{% block expiration_options %}
|
||||||
<select id="token-expiration-seconds"
|
<select id="token-expiration-seconds"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
<option value="3600">1 Day</option>
|
<!-- unit used for each value is `seconds` -->
|
||||||
<option value="86400">1 Week</option>
|
<option value="3600">1 Hour</option>
|
||||||
<option value="604800">1 Month</option>
|
<option value="86400">1 Day</option>
|
||||||
|
<option value="604800">1 Week</option>
|
||||||
<option value="" selected="selected">Never</option>
|
<option value="" selected="selected">Never</option>
|
||||||
</select>
|
</select>
|
||||||
{% endblock expiration_options %}
|
{% endblock expiration_options %}
|
||||||
|
Reference in New Issue
Block a user