Repository: jupyterhub/jupyterhub

Issue:
Upgrade non-admin parts of UI to Bootstrap 5
### Proposed change

Currently, we are on Bootstrap 3, which is almost 10 years old. We should upgrade to Bootstrap 5!

This issue is focused on bootstrap 5 for our non-admin panel, as the admin panel is using react and already on bootstrap 4.

## Switch to webpack for CSS customization

We customize our CSS with bootstrap with custom less stuff. We should switch to using webpack instead, which would allow us to use https://getbootstrap.com/docs/5.0/getting-started/webpack/ for customization.

## Files to be modified

Now that the admin panel is react, this is simpler. The following template files need to be modified to support bootstrap 5

- [ ] 404.html
- [ ] admin.html
- [ ] error.html
- [ ] home.html
- [ ] login.html
- [ ] logout.html
- [ ] not_running.html
- [ ] oauth.html
- [ ] page.html
- [ ] spawn.html
- [ ] spawn_pending.html
- [ ] stop_pending.html
- [ ] token.html


Discussion:

Resolving patch:
diff --git a/jupyterhub/handlers/static.py b/jupyterhub/handlers/static.py
--- a/jupyterhub/handlers/static.py
+++ b/jupyterhub/handlers/static.py
@@ -15,7 +15,9 @@ def compute_etag(self):
         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/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -113,27 +113,34 @@ def run(self):
 
 
 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]
-
-        # check if any .less files are newer than the generated targets
+        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 .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 @@ def run(self):
             return
 
         self.run_command('js')
-        print("Building css with less")
-
-        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,
-        ]
+        print("Building 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):


Test patch:
diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py
--- 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
--- 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
--- 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/testing/jupyterhub_config.py b/testing/jupyterhub_config.py
--- a/testing/jupyterhub_config.py
+++ b/testing/jupyterhub_config.py
@@ -6,16 +6,25 @@
 
 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