>
diff --git a/jsx/src/components/CreateGroup/CreateGroup.jsx b/jsx/src/components/CreateGroup/CreateGroup.jsx
index 0f2bff5c..6c6ed89b 100644
--- a/jsx/src/components/CreateGroup/CreateGroup.jsx
+++ b/jsx/src/components/CreateGroup/CreateGroup.jsx
@@ -1,7 +1,9 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
+import { Button, Card } from "react-bootstrap";
import PropTypes from "prop-types";
+import { MainContainer } from "../../util/layout";
const CreateGroup = (props) => {
const [groupName, setGroupName] = useState(""),
@@ -24,85 +26,61 @@ const CreateGroup = (props) => {
const { createGroup, updateGroups } = props;
return (
- <>
-
- {errorAlert != null ? (
-
-
-
- {errorAlert}
- setErrorAlert(null)}
- >
- ×
-
-
-
+
+
+
+ Create Group
+
+
+
+ {
+ setGroupName(e.target.value.trim());
+ }}
+ >
- ) : (
- <>>
- )}
-
-
-
-
-
Create Group
-
-
-
- {
- setGroupName(e.target.value.trim());
- }}
- >
-
-
-
-
- Back
-
-
- {
- createGroup(groupName)
- .then((data) => {
- return data.status < 300
- ? updateGroups(0, limit)
- .then((data) => dispatchPageUpdate(data, 0))
- .then(() => navigate("/groups"))
- .catch(() =>
- setErrorAlert(`Could not update groups list.`),
- )
- : setErrorAlert(
- `Failed to create group. ${
- data.status == 409
- ? "Group already exists."
- : ""
- }`,
- );
- })
- .catch(() => setErrorAlert(`Failed to create group.`));
- }}
- >
- Create
-
-
-
-
-
-
- >
+
+
+
+
+ Back
+
+
+
+ {
+ createGroup(groupName)
+ .then((data) => {
+ return data.status < 300
+ ? updateGroups(0, limit)
+ .then((data) => dispatchPageUpdate(data, 0))
+ .then(() => navigate("/groups"))
+ .catch(() =>
+ setErrorAlert(`Could not update groups list.`),
+ )
+ : setErrorAlert(
+ `Failed to create group. ${
+ data.status == 409 ? "Group already exists." : ""
+ }`,
+ );
+ })
+ .catch(() => setErrorAlert(`Failed to create group.`));
+ }}
+ >
+ Create
+
+
+
+
);
};
diff --git a/jsx/src/components/DynamicTable/DynamicTable.jsx b/jsx/src/components/DynamicTable/DynamicTable.jsx
index 0d3b07d1..7f6b9144 100644
--- a/jsx/src/components/DynamicTable/DynamicTable.jsx
+++ b/jsx/src/components/DynamicTable/DynamicTable.jsx
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import "./table-select.css";
import PropTypes from "prop-types";
+import { Button } from "react-bootstrap";
const DynamicTable = (props) => {
var [message, setMessage] = useState(""),
@@ -94,8 +95,8 @@ const DynamicTable = (props) => {
/>
- {
propvalues.splice(i, 1);
propkeys.splice(i, 1);
@@ -110,7 +111,7 @@ const DynamicTable = (props) => {
}}
>
Delete
-
+
);
@@ -150,15 +151,14 @@ const DynamicTable = (props) => {
/>
- handleAddItem()}
>
Add Item
-
+
diff --git a/jsx/src/components/DynamicTable/table-select.css b/jsx/src/components/DynamicTable/table-select.css
index f697604a..33d27db2 100644
--- a/jsx/src/components/DynamicTable/table-select.css
+++ b/jsx/src/components/DynamicTable/table-select.css
@@ -1,14 +1,12 @@
@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;
diff --git a/jsx/src/components/EditUser/EditUser.jsx b/jsx/src/components/EditUser/EditUser.jsx
index a80c77d5..10c9f06f 100644
--- a/jsx/src/components/EditUser/EditUser.jsx
+++ b/jsx/src/components/EditUser/EditUser.jsx
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import PropTypes from "prop-types";
import { Link, useLocation, useNavigate } from "react-router-dom";
+import { Button, Card } from "react-bootstrap";
+import { MainContainer } from "../../util/layout";
const EditUser = (props) => {
const limit = useSelector((state) => state.limit),
@@ -39,129 +41,103 @@ const EditUser = (props) => {
[admin, setAdmin] = useState(has_admin);
return (
- <>
-
- {errorAlert != null ? (
-
-
-
- {errorAlert}
- setErrorAlert(null)}
- >
- ×
-
-
+
+
+
+ Editing user {username}
+
+
+
- ) : (
- <>>
- )}
-
-
-
-
-
Editing user {username}
-
-
-
-
- {
- setUpdatedUsername(e.target.value);
- }}
- >
-
- setAdmin(!admin)}
- />
-
- Admin
-
- {
- e.preventDefault();
- deleteUser(username)
- .then((data) => {
- data.status < 300
- ? updateUsers(0, limit)
- .then((data) => dispatchPageChange(data, 0))
- .then(() => navigate("/"))
- .catch(() =>
- setErrorAlert(
- `Could not update users list.`,
- ),
- )
- : setErrorAlert(`Failed to edit user.`);
- })
- .catch(() => {
- setErrorAlert(`Failed to edit user.`);
- });
- }}
- >
- Delete user
-
-
-
-
-
-
- Back
-
-
- {
- e.preventDefault();
- if (updatedUsername == "" && admin == has_admin) {
- noChangeEvent();
- return;
- } else {
- editUser(
- username,
- updatedUsername != "" ? updatedUsername : username,
- admin,
- )
- .then((data) => {
- data.status < 300
- ? updateUsers(0, limit)
- .then((data) => dispatchPageChange(data, 0))
- .then(() => navigate("/"))
- .catch(() =>
- setErrorAlert(`Could not update users list.`),
- )
- : setErrorAlert(`Failed to edit user.`);
- })
- .catch(() => {
- setErrorAlert(`Failed to edit user.`);
- });
- }
- }}
- >
- Apply
-
-
-
-
-
-
- >
+
+
+
+
+ Back
+
+
+ {
+ e.preventDefault();
+ if (updatedUsername == "" && admin == has_admin) {
+ noChangeEvent();
+ return;
+ } else {
+ editUser(
+ username,
+ updatedUsername != "" ? updatedUsername : username,
+ admin,
+ )
+ .then((data) => {
+ data.status < 300
+ ? updateUsers(0, limit)
+ .then((data) => dispatchPageChange(data, 0))
+ .then(() => navigate("/"))
+ .catch(() =>
+ setErrorAlert(`Could not update users list.`),
+ )
+ : setErrorAlert(`Failed to edit user.`);
+ })
+ .catch(() => {
+ setErrorAlert(`Failed to edit user.`);
+ });
+ }
+ }}
+ >
+ Apply
+
+ {
+ e.preventDefault();
+ deleteUser(username)
+ .then((data) => {
+ data.status < 300
+ ? updateUsers(0, limit)
+ .then((data) => dispatchPageChange(data, 0))
+ .then(() => navigate("/"))
+ .catch(() =>
+ setErrorAlert(`Could not update users list.`),
+ )
+ : setErrorAlert(`Failed to edit user.`);
+ })
+ .catch(() => {
+ setErrorAlert(`Failed to edit user.`);
+ });
+ }}
+ >
+ Delete user
+
+
+
+
);
};
diff --git a/jsx/src/components/GroupEdit/GroupEdit.jsx b/jsx/src/components/GroupEdit/GroupEdit.jsx
index 0f95c62f..b856cb89 100644
--- a/jsx/src/components/GroupEdit/GroupEdit.jsx
+++ b/jsx/src/components/GroupEdit/GroupEdit.jsx
@@ -2,8 +2,10 @@ import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Link, useNavigate, useLocation } from "react-router-dom";
import PropTypes from "prop-types";
+import { Button, Card } from "react-bootstrap";
import GroupSelect from "../GroupSelect/GroupSelect";
import DynamicTable from "../DynamicTable/DynamicTable";
+import { MainContainer } from "../../util/layout";
const GroupEdit = (props) => {
const [selected, setSelected] = useState([]),
@@ -34,8 +36,6 @@ const GroupEdit = (props) => {
validateUser,
} = props;
- console.log("group edit", location, location.state);
-
useEffect(() => {
if (!location.state) {
navigate("/groups");
@@ -49,47 +49,26 @@ const GroupEdit = (props) => {
const [propvalues, setPropValues] = useState([]);
return (
-
- {errorAlert != null ? (
-
-
-
- {errorAlert}
- setErrorAlert(null)}
- >
- ×
-
-
-
-
- ) : (
- <>>
- )}
-
-
-
Editing Group {group_data.name}
-
-
Manage group members
-
-
-
{
- setSelected(selection);
- setChanged(true);
- }}
- />
-
-
-
Manage group properties
-
-
-
-
+
+ Editing Group {group_data.name}
+
+
+ Manage group members
+
+
+ {
+ setSelected(selection);
+ setChanged(true);
+ }}
+ />
+
+
+ Manage group properties
+
+
{
//Add keys
/>
-
-
-
-
-
-
- Back
-
+
+
+
+
+
+
+
+ Back
+
+
- {
// check for changes
let new_users = selected.filter(
@@ -158,15 +139,12 @@ const GroupEdit = (props) => {
}}
>
Apply
-
-
-
-
-
+ {
var groupName = group_data.name;
deleteGroup(groupName)
@@ -182,12 +160,13 @@ const GroupEdit = (props) => {
}}
>
Delete Group
-
-
-
-
-
-
+
+
+
+
+
+
+
);
};
diff --git a/jsx/src/components/GroupSelect/GroupSelect.jsx b/jsx/src/components/GroupSelect/GroupSelect.jsx
index 2f48bf3e..d2619ea4 100644
--- a/jsx/src/components/GroupSelect/GroupSelect.jsx
+++ b/jsx/src/components/GroupSelect/GroupSelect.jsx
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
+import { Button } from "react-bootstrap";
import "./group-select.css";
const GroupSelect = (props) => {
@@ -12,92 +13,80 @@ const GroupSelect = (props) => {
if (!users) return null;
return (
-
+ <>
{error != null ? (
-
+
{error}
) : (
<>>
)}
-
-
- {
- setUsername(e.target.value);
- }}
- />
-
-
+ {
+ setUsername(e.target.value);
+ }}
+ />
+ {
+ validateUser(username).then((exists) => {
+ if (exists && !selected.includes(username)) {
+ let updated_selection = selected.concat([username]);
+ onChange(updated_selection, users);
+ setUsername("");
+ setSelected(updated_selection);
+ if (error != null) setError(null);
+ } else if (!exists) {
+ setError(`"${username}" is not a valid JupyterHub user.`);
+ }
+ });
+ }}
+ >
+ Add user
+
+
+
+
+
+ {selected.map((e, i) => (
+
{
- validateUser(username).then((exists) => {
- if (exists && !selected.includes(username)) {
- let updated_selection = selected.concat([username]);
- onChange(updated_selection, users);
- setUsername("");
- setSelected(updated_selection);
- if (error != null) setError(null);
- } else if (!exists) {
- setError(`"${username}" is not a valid JupyterHub user.`);
- }
- });
+ let updated_selection = selected
+ .slice(0, i)
+ .concat(selected.slice(i + 1));
+ onChange(updated_selection, users);
+ setSelected(updated_selection);
}}
>
- Add user
-
-
-
-
-
-
-
-
- {selected.map((e, i) => (
+ {e}
+
+ ))}
+ {users.map((e, i) =>
+ selected.includes(e) ? undefined : (
{
- let updated_selection = selected
- .slice(0, i)
- .concat(selected.slice(i + 1));
+ let updated_selection = selected.concat([e]);
onChange(updated_selection, users);
setSelected(updated_selection);
}}
>
{e}
- ))}
- {users.map((e, i) =>
- selected.includes(e) ? undefined : (
-
{
- let updated_selection = selected.concat([e]);
- onChange(updated_selection, users);
- setSelected(updated_selection);
- }}
- >
- {e}
-
- ),
- )}
-
+ ),
+ )}
-
-
-
+ >
);
};
diff --git a/jsx/src/components/Groups/Groups.jsx b/jsx/src/components/Groups/Groups.jsx
index 0bc4392f..052769fa 100644
--- a/jsx/src/components/Groups/Groups.jsx
+++ b/jsx/src/components/Groups/Groups.jsx
@@ -2,9 +2,11 @@ import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
+import { Button, Card } from "react-bootstrap";
import { Link, useNavigate } from "react-router-dom";
import { usePaginationParams } from "../../util/paginationParams";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
+import { MainContainer } from "../../util/layout";
const Groups = (props) => {
const groups_data = useSelector((state) => state.groups_data);
@@ -41,59 +43,53 @@ const Groups = (props) => {
}
return (
-
-
-
-
-
-
Groups
-
-
-
- {groups_data.length > 0 ? (
- groups_data.map((e, i) => (
-
-
- {e.users.length + " users"}
-
-
- {e.name}
-
-
- ))
- ) : (
-
-
no groups created...
-
- )}
-
-
setOffset(offset + limit)}
- prev={() => setOffset(offset - limit)}
- handleLimit={handleLimit}
- />
-
-
-
- Back
-
- {
- navigate("/create-group");
- }}
- >
- New Group
-
-
-
-
-
-
+
+
+
+ Groups
+
+
+
+ {groups_data.length > 0 ? (
+ groups_data.map((e, i) => (
+
+
+ {e.users.length + " users"}
+
+
+ {e.name}
+
+
+ ))
+ ) : (
+
+
no groups created...
+
+ )}
+
+ setOffset(offset + limit)}
+ prev={() => setOffset(offset - limit)}
+ handleLimit={handleLimit}
+ />
+
+
+
+
+ Back
+
+
+
+
+ New Group
+
+
+
+
);
};
diff --git a/jsx/src/components/PaginationFooter/PaginationFooter.jsx b/jsx/src/components/PaginationFooter/PaginationFooter.jsx
index 803c85c8..444dea2c 100644
--- a/jsx/src/components/PaginationFooter/PaginationFooter.jsx
+++ b/jsx/src/components/PaginationFooter/PaginationFooter.jsx
@@ -1,6 +1,6 @@
import React from "react";
import PropTypes from "prop-types";
-import { FormControl } from "react-bootstrap";
+import { Button, FormControl } from "react-bootstrap";
import "./pagination-footer.css";
@@ -13,7 +13,7 @@ const PaginationFooter = (props) => {
{total ? `of ${total}` : ""}
{offset >= 1 ? (
-
+
{
>
Previous
-
+
) : (
-
+
Previous
-
+
)}
{offset + visible < total ? (
-
+
{
>
Next
-
+
) : (
-
+
Next
-
+
)}
Items per page:
diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.jsx b/jsx/src/components/ServerDashboard/ServerDashboard.jsx
index 2d8a28a3..7d6f2d21 100644
--- a/jsx/src/components/ServerDashboard/ServerDashboard.jsx
+++ b/jsx/src/components/ServerDashboard/ServerDashboard.jsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState, Fragment } from "react";
import { useSelector, useDispatch } from "react-redux";
import { debounce } from "lodash";
import PropTypes from "prop-types";
+import ErrorAlert from "../../util/error";
import {
Button,
@@ -151,11 +152,20 @@ const ServerDashboard = (props) => {
setNameFilter(event.target.value);
}, 300);
- const ServerButton = ({ server, user, action, name, extraClass }) => {
+ const ServerButton = ({
+ server,
+ user,
+ action,
+ name,
+ variant,
+ extraClass,
+ }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
- {
setIsDisabled(true);
@@ -183,7 +193,7 @@ const ServerDashboard = (props) => {
}}
>
{name}
-
+
);
};
@@ -196,7 +206,8 @@ const ServerDashboard = (props) => {
user,
action: stopServer,
name: "Stop Server",
- extraClass: "btn-danger stop-button",
+ variant: "danger",
+ extraClass: "stop-button",
});
};
const DeleteServerButton = ({ server, user }) => {
@@ -212,7 +223,8 @@ const ServerDashboard = (props) => {
user,
action: deleteServer,
name: "Delete Server",
- extraClass: "btn-danger stop-button",
+ variant: "danger",
+ extraClass: "stop-button",
});
};
@@ -225,7 +237,8 @@ const ServerDashboard = (props) => {
user,
action: startServer,
name: server.pending ? "Server is pending" : "Start Server",
- extraClass: "btn-success start-button",
+ variant: "success",
+ extraClass: "start-button",
});
};
@@ -239,7 +252,9 @@ const ServerDashboard = (props) => {
server.name ? "/" + server.name : ""
}`}
>
- Spawn Page
+
+ Spawn Page
+
);
};
@@ -250,15 +265,18 @@ const ServerDashboard = (props) => {
}
return (
- Access Server
+
+ Access Server
+
);
};
const EditUserButton = ({ user }) => {
return (
-
navigate("/edit-user", {
state: {
@@ -269,7 +287,7 @@ const ServerDashboard = (props) => {
}
>
Edit User
-
+
);
};
@@ -300,7 +318,7 @@ const ServerDashboard = (props) => {
}, {});
return (
{
variant={open ? "secondary" : "primary"}
size="sm"
>
-
+
{" "}
@@ -402,26 +420,9 @@ const ServerDashboard = (props) => {
return (
- {errorAlert != null ? (
-
-
-
- {errorAlert}
- setErrorAlert(null)}
- >
- ×
-
-
-
-
- ) : (
- <>>
- )}
+
-
+
{
onChange={handleSearch}
/>
-
- {/* div.checkbox required for BS3 CSS */}
-
-
- {
- setStateFilter(event.target.checked ? "active" : null);
- }}
- />
+
+
+ {
+ setStateFilter(event.target.checked ? "active" : null);
+ }}
+ />
+
{"only active servers"}
-
-
+
+
-
- {"> Manage Groups"}
+
+
+
+ {"Manage Groups"}
+
+
@@ -565,7 +571,7 @@ const ServerDashboard = (props) => {
Stop All
{/* spacing between start/stop and Shutdown */}
-
+
{/* Shutdown Jupyterhub */}
{
+ const { errorAlert, setErrorAlert } = props;
+ if (!errorAlert) {
+ return <>>;
+ }
+ return (
+
+
+
+ {errorAlert}
+ setErrorAlert(null)}
+ >
+
+
+
+ );
+};
+
+ErrorAlert.propTypes = {
+ errorAlert: PropTypes.string,
+ setErrorAlert: PropTypes.func,
+};
+
+export default ErrorAlert;
diff --git a/jsx/src/util/layout.jsx b/jsx/src/util/layout.jsx
new file mode 100644
index 00000000..54403890
--- /dev/null
+++ b/jsx/src/util/layout.jsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { withProps } from "recompose";
+import { Col, Row, Container } from "react-bootstrap";
+import PropTypes from "prop-types";
+import ErrorAlert from "./error";
+
+export const MainCol = (props) => {
+ // main column layout
+ // sets default width, span
+ return withProps({
+ md: { span: 10, offset: 1 },
+ lg: { span: 8, offset: 2 },
+ ...props,
+ })(Col)();
+};
+
+export const MainContainer = (props) => {
+ // default container for an admin page
+ // adds errorAlert and sets main column width
+ props = props || {};
+ return (
+
+
+
+ {props.children}
+
+
+ );
+};
+
+MainContainer.propTypes = {
+ errorAlert: PropTypes.string,
+ setErrorAlert: PropTypes.func,
+ children: PropTypes.array,
+};
diff --git a/jupyterhub/handlers/static.py b/jupyterhub/handlers/static.py
index e9c54199..f5608e06 100644
--- a/jupyterhub/handlers/static.py
+++ b/jupyterhub/handlers/static.py
@@ -15,7 +15,9 @@ class CacheControlStaticFilesHandler(StaticFileHandler):
return None
def set_extra_headers(self, path):
- if "v" not in self.request.arguments:
+ if "v" not in self.request.arguments or self.settings.get(
+ "no_cache_static", False
+ ):
self.add_header("Cache-Control", "no-cache")
diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py
index b3821948..e3ebda64 100644
--- a/jupyterhub/tests/browser/test_browser.py
+++ b/jupyterhub/tests/browser/test_browser.py
@@ -329,6 +329,64 @@ async def open_home_page(app, browser, user):
await expect(browser).to_have_url(re.compile(".*/hub/home"))
+async def test_home_nav_collapse(app, browser, user_special_chars):
+ user = user_special_chars.user
+ await open_home_page(app, browser, user)
+ nav = browser.locator(".navbar")
+ navbar_collapse = nav.locator(".navbar-collapse")
+ logo = nav.locator("#jupyterhub-logo")
+ home = nav.get_by_text("Home")
+ logout_name = nav.get_by_text(user.name)
+ logout_btn = nav.get_by_text("Logout")
+ toggler = nav.locator(".navbar-toggler")
+
+ await expect(nav).to_be_visible()
+
+ await browser.set_viewport_size({"width": 640, "height": 480})
+ # links visible, nav items visible, collapse not visible
+ await expect(logo).to_be_visible()
+ await expect(home).to_be_visible()
+ await expect(logout_name).to_be_visible()
+ await expect(logout_btn).to_be_visible()
+ await expect(toggler).not_to_be_visible()
+
+ # below small breakpoint (576px)
+ await browser.set_viewport_size({"width": 500, "height": 480})
+ # logo visible, links and logout not visible, toggler visible
+ await expect(logo).to_be_visible()
+ await expect(home).not_to_be_visible()
+ await expect(logout_name).not_to_be_visible()
+ await expect(logout_btn).not_to_be_visible()
+ await expect(toggler).to_be_visible()
+
+ # click toggler, links should be visible
+ await toggler.click()
+ # wait for expand to finish
+ # expand animates through `collapse -> collapsing -> collapse show`
+ await expect(navbar_collapse).to_have_class(re.compile(r"\bshow\b"))
+ await expect(home).to_be_visible()
+ await expect(logout_name).to_be_visible()
+ await expect(logout_btn).to_be_visible()
+ await expect(toggler).to_be_visible()
+ # wait for expand animation
+ # click toggler again, links should hide
+ # need to wait for expand to complete
+ await toggler.click()
+ await expect(navbar_collapse).not_to_have_class(re.compile(r"\bshow\b"))
+ await expect(home).not_to_be_visible()
+ await expect(logout_name).not_to_be_visible()
+ await expect(logout_btn).not_to_be_visible()
+ await expect(toggler).to_be_visible()
+
+ # resize, should re-show
+ await browser.set_viewport_size({"width": 640, "height": 480})
+ await expect(logo).to_be_visible()
+ await expect(home).to_be_visible()
+ await expect(logout_name).to_be_visible()
+ await expect(logout_btn).to_be_visible()
+ await expect(toggler).not_to_be_visible()
+
+
async def test_start_button_server_not_started(app, browser, user_special_chars):
"""verify that when server is not started one button is available,
after starting 2 buttons are available"""
@@ -413,7 +471,7 @@ async def test_token_request_form_and_panel(app, browser, user_special_chars):
"""verify elements of the request token form"""
await open_token_page(app, browser, user_special_chars.user)
- request_btn = browser.locator('//div[@class="text-center"]').get_by_role("button")
+ request_btn = browser.locator('//button[@type="submit"]')
expected_btn_name = 'Request new API token'
# check if the request token button is enabled
# check the buttons name
@@ -455,7 +513,7 @@ async def test_token_request_form_and_panel(app, browser, user_special_chars):
expected_panel_token_heading = "Your new API Token"
token_area = browser.locator('#token-area')
await expect(token_area).to_be_visible()
- token_area_heading = token_area.locator('//div[@class="panel-heading"]')
+ token_area_heading = token_area.locator('div.card-header')
await expect(token_area_heading).to_have_text(expected_panel_token_heading)
token_result = browser.locator('#token-result')
await expect(token_result).not_to_be_empty()
@@ -463,7 +521,7 @@ async def test_token_request_form_and_panel(app, browser, user_special_chars):
# verify that "Your new API Token" panel is hidden after refresh the page
await browser.reload(wait_until="load")
await expect(token_area).to_be_hidden()
- api_token_table_area = browser.locator('//div[@class="row"]').nth(2)
+ api_token_table_area = browser.locator("div#api-tokens-section")
await expect(api_token_table_area.get_by_role("table")).to_be_visible()
expected_table_name = "API Tokens"
await expect(api_token_table_area.get_by_role("heading")).to_have_text(
@@ -516,7 +574,7 @@ async def test_request_token_expiration(
# reload the page
await browser.reload(wait_until="load")
# API Tokens table: verify that elements are displayed
- api_token_table_area = browser.locator("div#api-tokens-section").nth(0)
+ api_token_table_area = browser.locator("div#api-tokens-section")
await expect(api_token_table_area.get_by_role("table")).to_be_visible()
await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1)
@@ -619,12 +677,14 @@ async def test_request_token_permissions(
error_message = await error_dialog.locator(".modal-body").inner_text()
assert "API request failed (400)" in error_message
assert expected_error in error_message
+ await error_dialog.locator("button[aria-label='Close']").click()
+ await expect(error_dialog).not_to_be_visible()
return
await browser.reload(wait_until="load")
# API Tokens table: verify that elements are displayed
- api_token_table_area = browser.locator("div#api-tokens-section").nth(0)
+ api_token_table_area = browser.locator("div#api-tokens-section")
await expect(api_token_table_area.get_by_role("table")).to_be_visible()
await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1)
@@ -670,9 +730,7 @@ async def test_revoke_token(app, browser, token_type, user_special_chars):
await browser.wait_for_load_state("load")
await expect(browser).to_have_url(re.compile(".*/hub/token"))
if token_type == "both" or token_type == "request_by_user":
- request_btn = browser.locator('//div[@class="text-center"]').get_by_role(
- "button"
- )
+ request_btn = browser.locator('//button[@type="submit"]')
await request_btn.click()
# wait for token response to show up on the page
await browser.wait_for_load_state("load")
@@ -879,9 +937,9 @@ async def test_oauth_page(
# login user
await login(browser, user.name, password=str(user.name))
- auth_btn = browser.locator('//input[@type="submit"]')
+ auth_btn = browser.locator('//button[@type="submit"]')
await expect(auth_btn).to_be_enabled()
- text_permission = browser.get_by_role("paragraph")
+ text_permission = browser.get_by_role("paragraph").nth(1)
await expect(text_permission).to_contain_text(f"JupyterHub service {service.name}")
await expect(text_permission).to_contain_text(f"oauth URL: {expected_redirect_url}")
@@ -1348,7 +1406,7 @@ async def test_singleuser_xsrf(
# visit target user, sets credentials for second server
await browser.goto(public_url(app, target_user))
await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize"))
- auth_button = browser.locator('//input[@type="submit"]')
+ auth_button = browser.locator('//button[@type="submit"]')
await expect(auth_button).to_be_enabled()
await auth_button.click()
await expect(browser).to_have_url(re.compile(rf".*/user/{target_user.name}/.*"))
diff --git a/jupyterhub/tests/browser/test_share.py b/jupyterhub/tests/browser/test_share.py
index 9d86f757..add68269 100644
--- a/jupyterhub/tests/browser/test_share.py
+++ b/jupyterhub/tests/browser/test_share.py
@@ -48,16 +48,16 @@ async def test_share_code_flow_full(app, browser, full_spawn, create_user_with_s
# back to accept-share page
await expect(browser).to_have_url(re.compile(r".*/accept-share"))
- header_text = await browser.locator("//h2").first.text_content()
+ header_text = await browser.locator("p.lead").first.text_content()
assert f"access {user.name}'s server" in header_text
assert f"You ({share_user.name})" in header_text
# TODO verify form
- submit = browser.locator('//input[@type="submit"]')
+ submit = browser.locator('//button[@type="submit"]')
await submit.click()
# redirects to server, which triggers oauth approval
await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize"))
- submit = browser.locator('//input[@type="submit"]')
+ submit = browser.locator('//button[@type="submit"]')
await submit.click()
# finally, we are at the server!
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
index 6814cef0..6a622d08 100644
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -1328,7 +1328,7 @@ async def test_services_nav_links(
r = await get_page("home", app, cookies=cookies)
assert r.status_code == 200
page = BeautifulSoup(r.text)
- nav = page.find("ul", class_="nav")
+ nav = page.find("ul", class_="navbar-nav")
# find service links
nav_urls = [a["href"] for a in nav.find_all("a")]
if present:
diff --git a/package-lock.json b/package-lock.json
index cb4b639d..c86fbfc7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,209 +10,205 @@
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
- "bootstrap": "^3.4.1",
- "font-awesome": "^4.7.0",
+ "@fortawesome/fontawesome-free": "^6.1.1",
+ "bootstrap": "^5.3.0",
"jquery": "^3.5.1",
"moment": "^2.29.4",
"requirejs": "^2.3.6"
},
"devDependencies": {
- "less": "^3.9.0",
- "less-plugin-clean-css": "^1.5.1",
- "prettier": "^1.16.4"
+ "sass": "^1.74.1"
}
},
- "node_modules/amdefine": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz",
- "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==",
- "dev": true,
- "engines": {
- "node": ">=0.4.2"
- }
- },
- "node_modules/bootstrap": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz",
- "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==",
+ "node_modules/@fortawesome/fontawesome-free": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
+ "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==",
+ "hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
- "node_modules/clean-css": {
- "version": "3.4.28",
- "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-3.4.28.tgz",
- "integrity": "sha512-aTWyttSdI2mYi07kWqHi24NUU9YlELFKGOAgFzZjDN1064DMAOy2FBuoyGmkKRlXkbpXd0EVHmiVkbKhKoirTw==",
- "dev": true,
- "dependencies": {
- "commander": "2.8.x",
- "source-map": "0.4.x"
- },
- "bin": {
- "cleancss": "bin/cleancss"
- },
- "engines": {
- "node": ">=0.10.0"
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
}
},
- "node_modules/clean-css/node_modules/source-map": {
- "version": "0.4.4",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz",
- "integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==",
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
- "amdefine": ">=0.0.4"
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
},
"engines": {
- "node": ">=0.8.0"
+ "node": ">= 8"
}
},
- "node_modules/commander": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz",
- "integrity": "sha512-+pJLBFVk+9ZZdlAOB5WuIElVPPth47hILFkmGym57aq8kwxsowvByvB0DHs1vQAhyMZzdcpTtF0VDKGkSDR4ZQ==",
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
- "dependencies": {
- "graceful-readlink": ">= 1.0.0"
- },
"engines": {
- "node": ">= 0.6.x"
- }
- },
- "node_modules/copy-anything": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz",
- "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==",
- "dev": true,
- "dependencies": {
- "is-what": "^3.14.1"
+ "node": ">=8"
},
"funding": {
- "url": "https://github.com/sponsors/mesqueeb"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/errno": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
- "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+ "node_modules/bootstrap": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
+ "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/twbs"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ }
+ ],
+ "peerDependencies": {
+ "@popperjs/core": "^2.11.8"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
- "optional": true,
"dependencies": {
- "prr": "~1.0.1"
+ "fill-range": "^7.0.1"
},
- "bin": {
- "errno": "cli.js"
- }
- },
- "node_modules/font-awesome": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
- "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==",
"engines": {
- "node": ">=0.10.3"
+ "node": ">=8"
}
},
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
- "optional": true
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
},
- "node_modules/graceful-readlink": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz",
- "integrity": "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==",
+ "node_modules/fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/immutable": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
+ "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
"dev": true
},
- "node_modules/image-size": {
- "version": "0.5.5",
- "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
- "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
- "optional": true,
- "bin": {
- "image-size": "bin/image-size.js"
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/is-what": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz",
- "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==",
- "dev": true
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
},
"node_modules/jquery": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz",
"integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ=="
},
- "node_modules/less": {
- "version": "3.13.1",
- "resolved": "https://registry.npmjs.org/less/-/less-3.13.1.tgz",
- "integrity": "sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw==",
- "dev": true,
- "dependencies": {
- "copy-anything": "^2.0.1",
- "tslib": "^1.10.0"
- },
- "bin": {
- "lessc": "bin/lessc"
- },
- "engines": {
- "node": ">=6"
- },
- "optionalDependencies": {
- "errno": "^0.1.1",
- "graceful-fs": "^4.1.2",
- "image-size": "~0.5.0",
- "make-dir": "^2.1.0",
- "mime": "^1.4.1",
- "native-request": "^1.0.5",
- "source-map": "~0.6.0"
- }
- },
- "node_modules/less-plugin-clean-css": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/less-plugin-clean-css/-/less-plugin-clean-css-1.5.1.tgz",
- "integrity": "sha512-Pc68AFHAEJO3aAoRvnUTW5iAiAv6y+TQsWLTTwVNqjiDno6xCvxz1AtfQl7Y0MZSpHPalFajM1EU4RB5UVINpw==",
- "dev": true,
- "dependencies": {
- "clean-css": "^3.0.1"
- },
- "engines": {
- "node": ">=0.4.2"
- }
- },
- "node_modules/make-dir": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
- "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
- "dev": true,
- "optional": true,
- "dependencies": {
- "pify": "^4.0.1",
- "semver": "^5.6.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/mime": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
- "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
- "dev": true,
- "optional": true,
- "bin": {
- "mime": "cli.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
@@ -221,42 +217,39 @@
"node": "*"
}
},
- "node_modules/native-request": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/native-request/-/native-request-1.1.0.tgz",
- "integrity": "sha512-uZ5rQaeRn15XmpgE0xoPL8YWqcX90VtCFglYwAgkvKM5e8fog+vePLAhHxuuv/gRkrQxIeh5U3q9sMNUrENqWw==",
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
- "optional": true
- },
- "node_modules/pify": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
- "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
- "dev": true,
- "optional": true,
"engines": {
- "node": ">=6"
+ "node": ">=0.10.0"
}
},
- "node_modules/prettier": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
- "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
- "bin": {
- "prettier": "bin-prettier.js"
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
},
"engines": {
- "node": ">=4"
+ "node": ">=8.10.0"
}
},
- "node_modules/prr": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
- "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
- "dev": true,
- "optional": true
- },
"node_modules/requirejs": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz",
@@ -269,31 +262,43 @@
"node": ">=0.4.0"
}
},
- "node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "node_modules/sass": {
+ "version": "1.74.1",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz",
+ "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==",
"dev": true,
- "optional": true,
+ "dependencies": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
"bin": {
- "semver": "bin/semver"
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
}
},
- "node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "node_modules/source-map-js": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"dev": true,
- "optional": true,
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/tslib": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
- "dev": true
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index fab5d46d..8b651bf3 100644
--- a/package.json
+++ b/package.json
@@ -10,17 +10,15 @@
},
"scripts": {
"postinstall": "python3 ./bower-lite",
- "fmt": "prettier --write --trailing-comma es5 share/jupyterhub/static/js/*",
- "lessc": "lessc"
+ "css": "sass --style compressed -I share/jupyterhub/static/components --source-map share/jupyterhub/static/scss/style.scss:share/jupyterhub/static/css/style.min.css",
+ "build:watch": "npm run css -- --watch"
},
"devDependencies": {
- "less": "^3.9.0",
- "less-plugin-clean-css": "^1.5.1",
- "prettier": "^1.16.4"
+ "sass": "^1.74.1"
},
"dependencies": {
- "bootstrap": "^3.4.1",
- "font-awesome": "^4.7.0",
+ "@fortawesome/fontawesome-free": "^6.1.1",
+ "bootstrap": "^5.3.0",
"jquery": "^3.5.1",
"moment": "^2.29.4",
"requirejs": "^2.3.6"
diff --git a/setup.py b/setup.py
index 4fdc4069..3adbf2f4 100755
--- a/setup.py
+++ b/setup.py
@@ -113,27 +113,34 @@ class NPM(BaseCommand):
class CSS(BaseCommand):
- description = "compile CSS from LESS"
+ description = "compile CSS"
def should_run(self):
- """Does less need to run?"""
- # from IPython.html.tasks.py
-
+ """Does CSS need to run?"""
css_targets = [pjoin(static, 'css', 'style.min.css')]
css_maps = [t + '.map' for t in css_targets]
targets = css_targets + css_maps
- if not all(os.path.exists(t) for t in targets):
- # some generated files don't exist
- return True
- earliest_target = sorted(mtime(t) for t in targets)[0]
+ earliest_target_mtime = float('inf')
+ earliest_target_name = ''
+ for t in targets:
+ if not os.path.exists(t):
+ print(f"Need to build css target: {t}")
+ return True
+ target_mtime = mtime(t)
+ if target_mtime < earliest_target_mtime:
+ earliest_target_name = t
+ earliest_target_mtime = target_mtime
- # check if any .less files are newer than the generated targets
+ # check if any .scss files are newer than the generated targets
for dirpath, dirnames, filenames in os.walk(static):
for f in filenames:
- if f.endswith('.less'):
+ if f.endswith('.scss'):
path = pjoin(static, dirpath, f)
timestamp = mtime(path)
- if timestamp > earliest_target:
+ if timestamp > earliest_target_mtime:
+ print(
+ f"mtime for {path} > {earliest_target_name}, needs update"
+ )
return True
return False
@@ -144,33 +151,18 @@ class CSS(BaseCommand):
return
self.run_command('js')
- print("Building css with less")
+ print("Building css")
- style_less = pjoin(static, 'less', 'style.less')
- style_css = pjoin(static, 'css', 'style.min.css')
- sourcemap = style_css + '.map'
-
- args = [
- 'npm',
- 'run',
- 'lessc',
- '--',
- '--clean-css',
- f'--source-map-basepath={static}',
- f'--source-map={sourcemap}',
- '--source-map-rootpath=../',
- style_less,
- style_css,
- ]
+ args = ['npm', 'run', 'css']
try:
check_call(args, cwd=here, shell=shell)
except OSError as e:
- print("Failed to run lessc: %s" % e, file=sys.stderr)
+ print("Failed to build css: %s" % e, file=sys.stderr)
print("You can install js dependencies with `npm install`", file=sys.stderr)
raise
# update data-files in case this created new files
self.distribution.data_files = get_data_files()
- assert not self.should_run(), 'CSS.run failed'
+ assert not self.should_run(), 'CSS.run did not produce up-to-date output'
class JSX(BaseCommand):
diff --git a/share/jupyterhub/static/js/utils.js b/share/jupyterhub/static/js/utils.js
index b57c72a0..2a5c82d4 100644
--- a/share/jupyterhub/static/js/utils.js
+++ b/share/jupyterhub/static/js/utils.js
@@ -118,7 +118,8 @@ define(["jquery"], function ($) {
var msg = log_ajax_error(jqXHR, status, error);
var dialog = $("#error-dialog");
dialog.find(".ajax-error").text(msg);
- dialog.modal();
+ var modal = new bootstrap.Modal(dialog[0]);
+ modal.show();
};
var utils = {
diff --git a/share/jupyterhub/static/less/style.less b/share/jupyterhub/static/less/style.less
deleted file mode 100644
index 8cd185b8..00000000
--- a/share/jupyterhub/static/less/style.less
+++ /dev/null
@@ -1,27 +0,0 @@
-/*!
-*
-* Twitter Bootstrap
-*
-*/
-@import "../components/bootstrap/less/bootstrap.less";
-@import "../components/bootstrap/less/responsive-utilities.less";
-
-/*!
-*
-* Font Awesome
-*
-*/
-@import "../components/font-awesome/less/font-awesome.less";
-@fa-font-path: "../components/font-awesome/fonts";
-
-/*!
-*
-* Jupyter
-*
-*/
-
-@import "./variables.less";
-@import "./page.less";
-@import "./admin.less";
-@import "./error.less";
-@import "./login.less";
diff --git a/share/jupyterhub/static/less/variables.less b/share/jupyterhub/static/less/variables.less
deleted file mode 100644
index 41adab1c..00000000
--- a/share/jupyterhub/static/less/variables.less
+++ /dev/null
@@ -1,27 +0,0 @@
-@border-radius-small: 2px;
-@border-radius-base: 2px;
-@border-radius-large: 3px;
-@navbar-height: 40px;
-@grid-float-breakpoint: @screen-xs-min;
-
-@navbar-default-color: #222;
-@navbar-default-link-color: @navbar-default-color;
-// darken background on hover, no change to text
-@navbar-default-link-hover-color: @navbar-default-color;
-@navbar-default-link-hover-bg: darken(@navbar-default-bg, 10%);
-
-@jupyter-orange: #f37524;
-
-@jupyter-red: #e34f21;
-// color blind-friendly alternative to red/green
-// from 5-class RdYlBu via colorbrewer.org
-// eliminate distinction between 'primary' and 'success'
-@brand-primary: #2c7bb6;
-@brand-success: @brand-primary;
-@brand-danger: #d7191c;
-
-@text-muted: #222;
-
-.btn-jupyter {
- .button-variant(#fff; @jupyter-orange; @jupyter-red);
-}
diff --git a/share/jupyterhub/static/less/admin.less b/share/jupyterhub/static/scss/admin.scss
similarity index 100%
rename from share/jupyterhub/static/less/admin.less
rename to share/jupyterhub/static/scss/admin.scss
diff --git a/share/jupyterhub/static/scss/cssvariables.css b/share/jupyterhub/static/scss/cssvariables.css
new file mode 100644
index 00000000..06b2e6db
--- /dev/null
+++ b/share/jupyterhub/static/scss/cssvariables.css
@@ -0,0 +1,20 @@
+/* CSS variables
+ note: SCSS variable overrides must be loaded _before_ bootstrap (variables.scss)
+ while CSS variable overrides must be loaded _after_ bootstrap (cssvariables.scss)
+*/
+
+.navbar {
+ /* higher contrast nav links by default */
+ --bs-navbar-color: rgba(black, 0.95);
+}
+
+.navbar-nav {
+ /* no color change on nav links
+ darken background on hover, no change to text
+ background part is in page.scss
+ */
+
+ --bs-nav-link-color: var(--bs-navbar-color);
+ --bs-nav-link-hover-color: var(--bs-nav-link-color);
+ --bs-nav-link-active-color: var(--bs-nav-link-color);
+}
diff --git a/share/jupyterhub/static/less/error.less b/share/jupyterhub/static/scss/error.scss
similarity index 92%
rename from share/jupyterhub/static/less/error.less
rename to share/jupyterhub/static/scss/error.scss
index ac0fd116..973f374b 100644
--- a/share/jupyterhub/static/less/error.less
+++ b/share/jupyterhub/static/scss/error.scss
@@ -6,7 +6,6 @@ div.error {
div.ajax-error {
padding: 1em;
text-align: center;
- .alert-danger();
}
div.error > h1 {
diff --git a/share/jupyterhub/static/less/login.less b/share/jupyterhub/static/scss/login.scss
similarity index 67%
rename from share/jupyterhub/static/less/login.less
rename to share/jupyterhub/static/scss/login.scss
index 43d3c289..b128bfd5 100644
--- a/share/jupyterhub/static/less/login.less
+++ b/share/jupyterhub/static/scss/login.scss
@@ -3,21 +3,19 @@
height: 80vh;
& #insecure-login-warning {
- .bg-warning();
+ background-color: $warning-bg-subtle;
padding: 10px;
}
.service-login {
text-align: center;
- display: table-cell;
vertical-align: middle;
- margin: auto auto 20% auto;
+ margin: auto;
}
form {
- display: table-cell;
vertical-align: middle;
- margin: auto auto 20% auto;
+ margin: auto;
width: 350px;
}
@@ -30,9 +28,9 @@
.auth-form-header {
padding: 10px 20px;
color: #fff;
- background: @jupyter-orange;
- border-radius: @border-radius-large @border-radius-large 0 0;
+ background: $jupyter-orange;
font-size: large;
+ border-radius: $border-radius-large $border-radius-large 0 0;
}
.auth-form-header > h1 {
@@ -45,6 +43,6 @@
padding: 20px;
border: thin silver solid;
border-top: none;
- border-radius: 0 0 @border-radius-large @border-radius-large;
+ border-radius: 0 0 $border-radius-large $border-radius-large;
}
}
diff --git a/share/jupyterhub/static/less/page.less b/share/jupyterhub/static/scss/page.scss
similarity index 59%
rename from share/jupyterhub/static/less/page.less
rename to share/jupyterhub/static/scss/page.scss
index dc46e033..9154143a 100644
--- a/share/jupyterhub/static/less/page.less
+++ b/share/jupyterhub/static/scss/page.scss
@@ -1,15 +1,25 @@
-@import "../components/bootstrap/less/variables.less";
-
-@logo-height: 28px;
+$logo-height: 28px;
+$grid-float-breakpoint: map-get($grid-breakpoints, "sm");
#jupyterhub-logo {
- @media (max-width: @grid-float-breakpoint) {
+ @media (max-width: $grid-float-breakpoint) {
// same length as the navbar-toggle element, displayed on responsive mode
margin-left: 15px;
}
.jpy-logo {
- height: @logo-height;
- margin-top: (@navbar-height - @logo-height) / 2;
+ height: $logo-height;
+ margin-top: calc($navbar-brand-height - $logo-height) / 2;
+ }
+}
+
+.navbar-nav {
+ .nav-link {
+ &:hover,
+ &:focus {
+ // no color change
+ color: var(--#{$prefix}navbar-color);
+ background-color: darken($body-tertiary-bg, 10%);
+ }
}
}
@@ -18,7 +28,7 @@
span {
// same as .nav > li > a from bootstrap, but applied to the span[id="login_widget"]
// or any other span that matches .nav > li > span, but only in responsive mode
- @media (max-width: @grid-float-breakpoint) {
+ @media (max-width: $grid-float-breakpoint) {
position: relative;
display: block;
padding: 10px 15px;
@@ -68,7 +78,16 @@
.form-control:focus {
box-shadow:
inset 0 1px 1px rgba(0, 0, 0, 0.075),
- 0 0 8px @jupyter-orange;
- border-color: @jupyter-orange;
- outline-color: @jupyter-orange;
+ 0 0 8px $jupyter-orange;
+ border-color: $jupyter-orange;
+ outline-color: $jupyter-orange;
+}
+
+.btn-jupyter {
+ @include button-variant(
+ $background: $jupyter-orange,
+ $border: $jupyter-red,
+ $color: #fff,
+ $hover-color: #fff
+ );
}
diff --git a/share/jupyterhub/static/scss/style.scss b/share/jupyterhub/static/scss/style.scss
new file mode 100644
index 00000000..4e8cff16
--- /dev/null
+++ b/share/jupyterhub/static/scss/style.scss
@@ -0,0 +1,71 @@
+/*!
+*
+* Bootstrap
+*
+*/
+
+// 1. Include functions first (so you can manipulate colors, SVGs, calc, etc)
+@import "../components/bootstrap/scss/functions"; // Required
+
+// 2. Include any default variable overrides here
+@import "./variables.scss";
+
+@import "../components/bootstrap/scss/bootstrap"; // Full bootstrap (maybe wasteful?)
+
+// // 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets)
+// @import "../components/bootstrap/scss/variables"; // Required
+// @import "../components/bootstrap/scss/variables-dark"; // Required
+//
+//
+// // 4. Include any default map overrides here
+//
+// // 5. Include remainder of required parts
+// @import "../components/bootstrap/scss/maps"; // Required
+// @import "../components/bootstrap/scss/mixins"; // Required
+// @import "../components/bootstrap/scss/root"; // Required
+//
+// // 6. Optionally include any other parts as needed
+// @import "../components/bootstrap/scss/utilities";
+// @import "../components/bootstrap/scss/reboot";
+// @import "../components/bootstrap/scss/type";
+// @import "../components/bootstrap/scss/images";
+// @import "../components/bootstrap/scss/navbar";
+// @import "../components/bootstrap/scss/alert";
+// @import "../components/bootstrap/scss/buttons";
+// @import "../components/bootstrap/scss/containers";
+// @import "../components/bootstrap/scss/grid";
+// @import "../components/bootstrap/scss/modal";
+
+// 7. Optionally include utilities API last to generate classes based on the Sass map in `_utilities.scss`
+// @import "../components/bootstrap/scss/utilities/api";
+
+// CSS variables must be loaded _after_ bootstrap to override
+@import "./cssvariables";
+
+// redefine .btn-xs, removed in bootstrap 4
+.btn-xs {
+ // $padding-y, $padding-x, $font-size, $border-radius
+ @include button-size(1px, 5px, 14px, 3px);
+}
+
+/*!
+*
+* Font Awesome
+*
+*/
+$fa-font-path: "../components/@fortawesome/fontawesome-free/webfonts";
+@import "../components/@fortawesome/fontawesome-free/scss/fontawesome";
+// You can include all the other styles the same as before
+@import "../components/@fortawesome/fontawesome-free/scss/regular.scss";
+@import "../components/@fortawesome/fontawesome-free/scss/solid.scss";
+
+/*!
+*
+* Jupyter
+*
+*/
+
+@import "./page.scss";
+@import "./admin.scss";
+@import "./error.scss";
+@import "./login.scss";
diff --git a/share/jupyterhub/static/scss/variables.scss b/share/jupyterhub/static/scss/variables.scss
new file mode 100644
index 00000000..dd221d49
--- /dev/null
+++ b/share/jupyterhub/static/scss/variables.scss
@@ -0,0 +1,11 @@
+$border-radius-large: 5px;
+
+$jupyter-orange: #f37524;
+$jupyter-red: #e34f21;
+
+// accessible alternative to red/green
+// from 5-class RdYlBu via colorbrewer.org
+// eliminate distinction between 'primary' and 'success'
+$primary: #2c7bb6;
+$success: $primary;
+$danger: #d7191c;
diff --git a/share/jupyterhub/templates/accept-share.html b/share/jupyterhub/templates/accept-share.html
index 01cc7e5b..af8b37b4 100644
--- a/share/jupyterhub/templates/accept-share.html
+++ b/share/jupyterhub/templates/accept-share.html
@@ -3,13 +3,14 @@
{% endblock %}
{% block main %}
-
+
+
+
Accept sharing invitation
-
-
+
You ({{ user.name }}) have been invited to access {{ owner.name }}'s server
- {%- if spawner.name %} ({{ spawner.name }}){%- endif %} at {{ spawner_url }} .
-
+ {%- if spawner.name %} ({{ spawner.name }}){%- endif %} at {{ spawner_url }}
+
{% if not spawner_ready %}
@@ -19,18 +20,18 @@
{% endif %}
-
+
+
+
+
+
{% endblock %}
diff --git a/share/jupyterhub/templates/admin.html b/share/jupyterhub/templates/admin.html
index 57194878..18247ee9 100644
--- a/share/jupyterhub/templates/admin.html
+++ b/share/jupyterhub/templates/admin.html
@@ -11,9 +11,7 @@
{% endblock %}
{% block footer %}
-
+
{% endif %}
{% if oauth_clients %}
diff --git a/testing/jupyterhub_config.py b/testing/jupyterhub_config.py
index c82e8511..b530f95f 100644
--- a/testing/jupyterhub_config.py
+++ b/testing/jupyterhub_config.py
@@ -6,16 +6,25 @@ to enable testing without administrative privileges.
c = get_config() # noqa
-from jupyterhub.auth import DummyAuthenticator
-
-c.JupyterHub.authenticator_class = DummyAuthenticator
+c.JupyterHub.authenticator_class = "dummy"
# Optionally set a global password that all users must use
# c.DummyAuthenticator.password = "your_password"
-from jupyterhub.spawner import SimpleLocalProcessSpawner
-
-c.JupyterHub.spawner_class = SimpleLocalProcessSpawner
+c.JupyterHub.spawner_class = "simple"
# only listen on localhost for testing
c.JupyterHub.bind_url = 'http://127.0.0.1:8000'
+
+# don't cache static files
+c.JupyterHub.tornado_settings = {
+ "no_cache_static": True,
+ "slow_spawn_timeout": 0,
+}
+
+c.JupyterHub.allow_named_servers = True
+c.JupyterHub.default_url = "/hub/home"
+
+# make sure admin UI is available and any user can login
+c.Authenticator.admin_users = {"admin"}
+c.Authenticator.allow_all = True