Repository: jupyterhub/jupyterhub

Issue:
Make admin page a full-on react app
<!-- Thank you for contributing. These HTML commments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Proposed change
<!-- Use this section to describe the feature you'd like to be added. -->

JupyterHub pages started out as *extremely* basic: just a start and stop button. They are getting more <del>complex</del> feature-rich with things like named servers, profiles, options forms, etc.

I think it's probably time that we move to a real UI framework, and I think react is a really good fit since we are mostly displaying tables of simple data types (list of users and running servers on admin, list of spawners on named-servers, etc.).

The admin page is currently our most complex page, with pagination, users, sorting, etc. I think this is probably a great place to start with rewriting as a react app with data-flow of users to display. This means, among other things, that things like pagination, etc. need to move to javascript. The HTML template can be greatly simplified and the only relevant Python code is the query for the user list (likely needs pagination in the REST API user list).

### Alternative options
<!-- Use this section to describe alternative options and why you've decided on the proposed feature above. -->

- Use another js framework
- Leave it alone

### Who would use this feature?
<!-- Describe the audience for this feature. This information will affect who chooses to work on the feature with you. -->

JupyterHub admins, especially those with lots of users

### How much effort will adding it take?
<!-- Try to estimate how much work adding this feature will require. This information will affect who chooses to work on the feature with you. -->

A moderate amount of effort to:

1. add react as a js dependency
2. rework the admin Jinja template to do much less
3. create react app from scratch to regenerate ~the same HTML we have now

### Who can do this work?
<!-- What skills are needed? Who can be recruited to add this feature? This information will affect who chooses to work on the feature with you. -->

Javascript/React/frontend developers, with some REST API experience.



Discussion:
I'm generally in favor of adding a modern frontend JS framework. The JupyterHub instance that I maintain at Ghent University has a fairly extensive Spawner options page, which is currently using jQuery for some dynamic features. Using React in other projects, it always feels like a step back when I need to _fight_ with jQuery to implement certain features.

One important note on using React is that you generally want to use Babel for transpiling the React JS code to vanilla JS for a few reasons:
- React uses JSX to define visual elements, which is not supported by browsers natively
- React is generally used with ES6 or higher, which is not supported by IE11 for instance. Otherwise your code gets unnecessarily complex, and a lot of the examples available won't work [without adapting them](https://reactjs.org/docs/react-without-es6.html).

Bringing in Babel means that we will need to bring in Webpack and the rest of the JS-ecosystem. I've already successfully integrated this with Flask by using [flask-webpack-loader](https://pypi.org/project/flask-webpack-loader/) which makes this relatively painless. My 5-second search doesn't show a one-on-one equivalent for Tornado, so we might need to implement that first.

I'm not familiar enough with Vue to do any assessment if it is usable without Babel/Webpack/etc.
I support the idea of making the admin page more of an "app" as a way to handle the new features.

One thing that keeps appearing on my radar more and more are libraries like https://stimulusjs.org/ (in combination with https://github.com/turbolinks/turbolinks for full "SPA feeling"). I like the idea of Stimulus as "serve plain HTML and sprinkle some JS on top". There are a few libraries like this, but I can't (re)find them in my history right now. I think one is called ~~Adrenalin~~ [AlpineJS](https://github.com/alpinejs/alpine) ~~or some such~~.

A reason to adopt a tool like this instead of react is that it would increase the number of people who can actively work on the admin panel app. A reason not to use it is that there are so many react developers that would have to learn a new thing to contribute here. I've not used Stimulus for anything beyond "tutorial level" work. I thin pypi.org uses it though. So this is more a point for consideration/get feedback on instead of "lets use it".
I definitely agree that adopting react (or any framework, probably) means adopting webpack as well. I think react plus `webpack --watch` is a fairly tolerable development experience for a project with our small scope. Tornado doesn't need to know anything about it since it's just a static file. I have used in-browser babel for jsx in one project (I don't even remember which one) which I found much nicer as a Python developer who bristles at compilation steps, but presumably not the right approach for production. Switching to react components may complicate some of our template customization features, so we'll need to keep an eye on that.

I would guess the number of people who can actively work on a react/jsx project would be higher than stimulus/turbolinks or just about anything else these days, but I'm not sure. This is part of why I strongly want us to *not* pick up Typescript here, which I have found to be a major source of resistance for new contributors (nteract or jupyterlab folks may disagree here).

I went with "no framework" because for the first few years, all we did was show two buttons and one simple list and a js framework is vast overkill for that. That's not so true anymore! So the tradeoffs are different now, and admin is our most complex page with the most view/input options (sorting, pagination, grouping) so a good place to see benefits.

We could also start small and do a lighter page, like the token generation page that does close to nothing. That would shift the task to being ~all setting up the react/webpack build pipeline (and tests!), plus a token (heh) page to test on.

> Using React in other projects, it always feels like a step back when I need to fight with jQuery to implement certain features.

You should already be free to use react in your custom spawn form if you want to, I think. You just need to load the react code on the page yourself.
Is there any scope for splitting the frontend web-UI/app from the backend JupyterHub API into a new repo? Which I guess is another way of saying could someone write an independent web-client that has all the funtionality of the current web-interface but uses only JupyterHub API calls?
@minrk Your approach means that we'll need to check in a compiled version of the JS-code. The big plus for this approach is that you can still checkout the JupyterHub-repo and start the JupyterHub-process without any changes. However, we need to be careful that the sourcecode and compiled version always match each other. Maybe we should add a testsuite in Travis that compiles the sourcecode and compares this to the compiled version to verify that it's the same? While doable, we'll need to keep a close eye on potential problems with mismatched versions of NodeJS on the developers machine vs. Travis creating inconsistent end results and thus a failing test.

@manics I don't think such a big bang approach is doable nor necessary. The frontend of JupyterHub is quite limited as-is, and having potentially multiple frontends to choose from has no added value whatsoever.
> Your approach means that we'll need to check in a compiled version of the JS-code. 

It does not. It means we need to include the compiled js in releases, but not the repo. We already do this in other Jupyter projects with a web UI (jupyterlab, notebook, etc.). There's already a javascript build step in jupyterhub (less->css and bundling js dependencies). Adopting js compilation would not have a significant impact on that.

> Is there any scope for splitting the frontend web-UI/app from the backend JupyterHub API into a new repo?

A case can be made for creating a proper npm package to organize the js, but I wouldn't make a new repo. The trend these days, especially in js-land, is toward "monorepos" as more maintainable, even if multiple packages in the repo are versioned independently. One could do experiments this way, though. I suspect the first thing one will run into is that the paging/sorting/etc. currently implemented in admin is *not* supported by the API! So that's something we'll need to add (and should anyway), regardless.
> There's already a javascript build step in jupyterhub (less->css and bundling js dependencies). Adopting js compilation would not have a significant impact on that.

Okay, I haven't had to dive into that part of the code yet, so I was unaware. After some digging around I now understand that `npm` is now already executed as part of building the `.whl` for JupyterHub, which is very neat! Thanks for explaining!

How would one go about replacing the current JINJA templates with a REACT app. My understanding of how the Jupyterhub system works is fairly limited, what steps can I take to better understand the current process of serving HTML files to users?
EDIT 
Did some more reading around, it seems you would need to get a general idea of how a tornado process works since JupyterHub is a tornado process(duh)... 
> How would one go about replacing the current JINJA templates with a REACT app. 

React just needs a reference to an element in the DOM to attach to. So it suffices to add an empty div and reference it in the Javascript that initializes the React-component.

eg.:

```html
<div id="xyz-container"></div>
```

and

```javascript
class Quiz extends React.Component { 
    render() {
       return (<h1>Hello world!</h1>);
    }
}
ReactDOM.render(<ComponentXYZ />, document.querySelector('#xyz-container'));
```
Tornado doesn't need to know anything about React. It just needs to output an empty ```<div>```
Also a major decision needed here would be whether to go with server-side rendering with react or not.

Wont the whole jinja templating be replaced with ajax calls?
> Also a major decision needed here would be whether to go with server-side rendering with react or not.

Definitely client side.

> Won't the whole jinja templating be replaced with ajax calls?

Not *entirely*, in that the basic frame will still come from a jinja template, but the main table of data will be moved to react, eliminating the iteration, macros, etc. from jinja. Yes, table data will instead come from REST (`GET /users`) which will need to support the query/sort/page parameters currently only implemented in the admin page handlers.

> what steps can I take to better understand the current process of serving HTML files to users?

The bulk of the HTML is created with a jinja template [in share/jupyterhub/templates/admin.html](https://github.com/jupyterhub/jupyterhub/blob/8a3790b01ff944c453ffcc0486149e2a58ffabea/share/jupyterhub/templates/admin.html). The user list is populated and passed to the template [in the AdminHandler](https://github.com/jupyterhub/jupyterhub/blob/8a3790b01ff944c453ffcc0486149e2a58ffabea/jupyterhub/handlers/pages.py#L469-L502).
how about using Vue for the overall frontend? any thoughts with vue?
Hello. This seems like an interesting task but may I clarify if this means we want to entirely replace jinja template with bundled/transpiled react app or we want to embed react components into the jinja template?
Hey all, currently working on this issue and have most of the UI work / integration done. Do we want to initially wrap this up as a hub managed service until it gets merged upstream or would it be best to start a PR with it fully integrated as the `/admin` endpoint?
Hey everyone, we've developed a react based JupyterHub admin front-end that should fit this issue. Currently it's packaged as a Hub service/extension and can be enabled through the config, but it'd also be easy to implement in the hub itself with a couple edits to the templates. Feel free to check it out and recommend edits / next steps: 
[https://github.com/MetroStar/jh-admin-dashboard](https://github.com/MetroStar/jh-admin-dashboard)

I'd also be happy to open a PR to add these updates to the Hub if there's interest, please reach out and comment 
[here](https://github.com/MetroStar/jh-admin-dashboard/issues/4).


Resolving patch:
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -457,82 +457,16 @@ class AdminHandler(BaseHandler):
     @web.authenticated
     @admin_only
     async def get(self):
-        pagination = Pagination(url=self.request.uri, config=self.config)
-        page, per_page, offset = pagination.get_page_args(self)
-
-        available = {'name', 'admin', 'running', 'last_activity'}
-        default_sort = ['admin', 'name']
-        mapping = {'running': orm.Spawner.server_id}
-        for name in available:
-            if name not in mapping:
-                table = orm.User if name != "last_activity" else orm.Spawner
-                mapping[name] = getattr(table, name)
-
-        default_order = {
-            'name': 'asc',
-            'last_activity': 'desc',
-            'admin': 'desc',
-            'running': 'desc',
-        }
-
-        sorts = self.get_arguments('sort') or default_sort
-        orders = self.get_arguments('order')
-
-        for bad in set(sorts).difference(available):
-            self.log.warning("ignoring invalid sort: %r", bad)
-            sorts.remove(bad)
-        for bad in set(orders).difference({'asc', 'desc'}):
-            self.log.warning("ignoring invalid order: %r", bad)
-            orders.remove(bad)
-
-        # add default sort as secondary
-        for s in default_sort:
-            if s not in sorts:
-                sorts.append(s)
-        if len(orders) < len(sorts):
-            for col in sorts[len(orders) :]:
-                orders.append(default_order[col])
-        else:
-            orders = orders[: len(sorts)]
-
-        # this could be one incomprehensible nested list comprehension
-        # get User columns
-        cols = [mapping[c] for c in sorts]
-        # get User.col.desc() order objects
-        ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
-
-        query = self.db.query(orm.User).outerjoin(orm.Spawner).distinct(orm.User.id)
-        subquery = query.subquery("users")
-        users = (
-            self.db.query(orm.User)
-            .select_entity_from(subquery)
-            .outerjoin(orm.Spawner)
-            .order_by(*ordered)
-            .limit(per_page)
-            .offset(offset)
-        )
-
-        users = [self._user_from_orm(u) for u in users]
-
-        running = []
-        for u in users:
-            running.extend(s for s in u.spawners.values() if s.active)
-
-        pagination.total = query.count()
-
         auth_state = await self.current_user.get_auth_state()
         html = await self.render_template(
             'admin.html',
             current_user=self.current_user,
             auth_state=auth_state,
             admin_access=self.settings.get('admin_access', False),
-            users=users,
-            running=running,
-            sort={s: o for s, o in zip(sorts, orders)},
             allow_named_servers=self.allow_named_servers,
             named_server_limit_per_user=self.named_server_limit_per_user,
             server_version='{} {}'.format(__version__, self.version_hash),
-            pagination=pagination,
+            api_page_limit=self.settings["app"].api_page_default_limit,
         )
         self.finish(html)
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -140,6 +140,7 @@ jobs:
         run: |
           npm install
           npm install -g configurable-http-proxy
+          npm install -g yarn
           npm list
 
       # NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
@@ -219,6 +220,9 @@ jobs:
         #        https://github.com/actions/runner/issues/241
         run: |
           pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
+      - name: Run yarn jest test
+        run: |
+          cd jsx && yarn && yarn test
       - name: Submit codecov report
         run: |
           codecov
diff --git a/jsx/src/components/AddUser/AddUser.test.js b/jsx/src/components/AddUser/AddUser.test.js
new file mode 100644
--- /dev/null
+++ b/jsx/src/components/AddUser/AddUser.test.js
@@ -0,0 +1,77 @@
+import React from "react";
+import Enzyme, { mount } from "enzyme";
+import AddUser from "./AddUser";
+import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
+import { Provider, useDispatch, useSelector } from "react-redux";
+import { createStore } from "redux";
+import { HashRouter } from "react-router-dom";
+
+Enzyme.configure({ adapter: new Adapter() });
+
+jest.mock("react-redux", () => ({
+  ...jest.requireActual("react-redux"),
+  useDispatch: jest.fn(),
+  useSelector: jest.fn(),
+}));
+
+describe("AddUser Component: ", () => {
+  var mockAsync = () =>
+    jest
+      .fn()
+      .mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
+
+  var addUserJsx = (callbackSpy) => (
+    <Provider store={createStore(() => {}, {})}>
+      <HashRouter>
+        <AddUser
+          addUsers={callbackSpy}
+          failRegexEvent={callbackSpy}
+          updateUsers={callbackSpy}
+          history={{ push: () => {} }}
+        />
+      </HashRouter>
+    </Provider>
+  );
+
+  var mockAppState = () => ({
+    limit: 3,
+  });
+
+  beforeEach(() => {
+    useDispatch.mockImplementation(() => {
+      return () => {};
+    });
+    useSelector.mockImplementation((callback) => {
+      return callback(mockAppState());
+    });
+  });
+
+  afterEach(() => {
+    useDispatch.mockClear();
+  });
+
+  it("Renders", () => {
+    let component = mount(addUserJsx(mockAsync()));
+    expect(component.find(".container").length).toBe(1);
+  });
+
+  it("Removes users when they fail Regex", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(addUserJsx(callbackSpy)),
+      textarea = component.find("textarea").first();
+    textarea.simulate("blur", { target: { value: "foo\nbar\n!!*&*" } });
+    let submit = component.find("#submit");
+    submit.simulate("click");
+    expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
+  });
+
+  it("Correctly submits admin", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(addUserJsx(callbackSpy)),
+      input = component.find("input").first();
+    input.simulate("change", { target: { checked: true } });
+    let submit = component.find("#submit");
+    submit.simulate("click");
+    expect(callbackSpy).toHaveBeenCalledWith([], true);
+  });
+});
diff --git a/jsx/src/components/CreateGroup/CreateGroup.test.js b/jsx/src/components/CreateGroup/CreateGroup.test.js
new file mode 100644
--- /dev/null
+++ b/jsx/src/components/CreateGroup/CreateGroup.test.js
@@ -0,0 +1,66 @@
+import React from "react";
+import Enzyme, { mount } from "enzyme";
+import CreateGroup from "./CreateGroup";
+import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
+import { Provider, useDispatch, useSelector } from "react-redux";
+import { createStore } from "redux";
+import { HashRouter } from "react-router-dom";
+import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
+
+Enzyme.configure({ adapter: new Adapter() });
+
+jest.mock("react-redux", () => ({
+  ...jest.requireActual("react-redux"),
+  useDispatch: jest.fn(),
+  useSelector: jest.fn(),
+}));
+
+describe("CreateGroup Component: ", () => {
+  var mockAsync = (result) =>
+    jest.fn().mockImplementation(() => Promise.resolve(result));
+
+  var createGroupJsx = (callbackSpy) => (
+    <Provider store={createStore(() => {}, {})}>
+      <HashRouter>
+        <CreateGroup
+          createGroup={callbackSpy}
+          updateGroups={callbackSpy}
+          history={{ push: () => {} }}
+        />
+      </HashRouter>
+    </Provider>
+  );
+
+  var mockAppState = () => ({
+    limit: 3,
+  });
+
+  beforeEach(() => {
+    useDispatch.mockImplementation(() => {
+      return () => () => {};
+    });
+    useSelector.mockImplementation((callback) => {
+      return callback(mockAppState());
+    });
+  });
+
+  afterEach(() => {
+    useDispatch.mockClear();
+  });
+
+  it("Renders", () => {
+    let component = mount(createGroupJsx());
+    expect(component.find(".container").length).toBe(1);
+  });
+
+  it("Calls createGroup on submit", () => {
+    let callbackSpy = mockAsync({ status: 200 }),
+      component = mount(createGroupJsx(callbackSpy)),
+      input = component.find("input").first(),
+      submit = component.find("#submit").first();
+    input.simulate("change", { target: { value: "" } });
+    submit.simulate("click");
+    expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
+    expect(component.find(".alert.alert-danger").length).toBe(0);
+  });
+});
diff --git a/jsx/src/components/EditUser/EditUser.test.js b/jsx/src/components/EditUser/EditUser.test.js
new file mode 100644
--- /dev/null
+++ b/jsx/src/components/EditUser/EditUser.test.js
@@ -0,0 +1,80 @@
+import React from "react";
+import Enzyme, { mount } from "enzyme";
+import EditUser from "./EditUser";
+import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
+import { Provider, useDispatch, useSelector } from "react-redux";
+import { createStore } from "redux";
+import { HashRouter } from "react-router-dom";
+
+Enzyme.configure({ adapter: new Adapter() });
+
+jest.mock("react-redux", () => ({
+  ...jest.requireActual("react-redux"),
+  useDispatch: jest.fn(),
+  useSelector: jest.fn(),
+}));
+
+describe("EditUser Component: ", () => {
+  var mockAsync = () =>
+    jest
+      .fn()
+      .mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
+  var mockSync = () => jest.fn();
+
+  var editUserJsx = (callbackSpy, empty) => (
+    <Provider store={createStore(() => {}, {})}>
+      <HashRouter>
+        <EditUser
+          location={
+            empty ? {} : { state: { username: "foo", has_admin: false } }
+          }
+          deleteUser={callbackSpy}
+          editUser={callbackSpy}
+          updateUsers={callbackSpy}
+          history={{ push: () => {} }}
+          failRegexEvent={callbackSpy}
+          noChangeEvent={callbackSpy}
+        />
+      </HashRouter>
+    </Provider>
+  );
+
+  var mockAppState = () => ({
+    limit: 3,
+  });
+
+  beforeEach(() => {
+    useDispatch.mockImplementation(() => {
+      return () => {};
+    });
+    useSelector.mockImplementation((callback) => {
+      return callback(mockAppState());
+    });
+  });
+
+  afterEach(() => {
+    useDispatch.mockClear();
+  });
+
+  it("Calls the delete user function when the button is pressed", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(editUserJsx(callbackSpy)),
+      deleteUser = component.find("#delete-user");
+    deleteUser.simulate("click");
+    expect(callbackSpy).toHaveBeenCalled();
+  });
+
+  it("Submits the edits when the button is pressed", () => {
+    let callbackSpy = mockSync(),
+      component = mount(editUserJsx(callbackSpy)),
+      submit = component.find("#submit");
+    submit.simulate("click");
+    expect(callbackSpy).toHaveBeenCalled();
+  });
+
+  it("Doesn't render when no data is provided", () => {
+    let callbackSpy = mockSync(),
+      component = mount(editUserJsx(callbackSpy, true));
+    expect(component.find(".container").length).toBe(0);
+  });
+});
diff --git a/jsx/src/components/GroupEdit/GroupEdit.test.jsx b/jsx/src/components/GroupEdit/GroupEdit.test.jsx
new file mode 100644
--- /dev/null
+++ b/jsx/src/components/GroupEdit/GroupEdit.test.jsx
@@ -0,0 +1,100 @@
+import React from "react";
+import Enzyme, { mount } from "enzyme";
+import GroupEdit from "./GroupEdit";
+import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
+import { Provider, useSelector } from "react-redux";
+import { createStore } from "redux";
+import { HashRouter } from "react-router-dom";
+import { act } from "react-dom/test-utils";
+import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
+
+Enzyme.configure({ adapter: new Adapter() });
+
+jest.mock("react-redux", () => ({
+  ...jest.requireActual("react-redux"),
+  useSelector: jest.fn(),
+}));
+
+describe("GroupEdit Component: ", () => {
+  var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve());
+
+  var okPacket = new Promise((resolve) => resolve(true));
+
+  var groupEditJsx = (callbackSpy) => (
+    <Provider store={createStore(() => {}, {})}>
+      <HashRouter>
+        <GroupEdit
+          location={{
+            state: {
+              group_data: { users: ["foo"], name: "group" },
+              callback: () => {},
+            },
+          }}
+          addToGroup={callbackSpy}
+          removeFromGroup={callbackSpy}
+          deleteGroup={callbackSpy}
+          history={{ push: () => callbackSpy }}
+          updateGroups={callbackSpy}
+          validateUser={jest.fn().mockImplementation(() => okPacket)}
+        />
+      </HashRouter>
+    </Provider>
+  );
+
+  var mockAppState = () => ({
+    limit: 3,
+  });
+
+  beforeEach(() => {
+    useSelector.mockImplementation((callback) => {
+      return callback(mockAppState());
+    });
+  });
+
+  afterEach(() => {
+    useSelector.mockClear();
+  });
+
+  it("Adds user from input to user selectables on button click", async () => {
+    let callbackSpy = mockAsync(),
+      component = mount(groupEditJsx(callbackSpy)),
+      input = component.find("#username-input"),
+      validateUser = component.find("#validate-user"),
+      submit = component.find("#submit");
+
+    input.simulate("change", { target: { value: "bar" } });
+    validateUser.simulate("click");
+    await act(() => okPacket);
+    submit.simulate("click");
+    expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
+  });
+
+  it("Removes a user recently added from input from the selectables list", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(groupEditJsx(callbackSpy)),
+      unsubmittedUser = component.find(".item.selected").last();
+    unsubmittedUser.simulate("click");
+    expect(component.find(".item").length).toBe(1);
+  });
+
+  it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(groupEditJsx(callbackSpy)),
+      groupUser = component.find(".item.selected").first();
+    groupUser.simulate("click");
+    expect(component.find(".item.unselected").length).toBe(1);
+    expect(component.find(".item").length).toBe(1);
+    // test deleteUser call
+    let submit = component.find("#submit");
+    submit.simulate("click");
+    expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
+  });
+
+  it("Calls deleteGroup on button click", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(groupEditJsx(callbackSpy)),
+      deleteGroup = component.find("#delete-group").first();
+    deleteGroup.simulate("click");
+    expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
+  });
+});
diff --git a/jsx/src/components/Groups/Groups.test.js b/jsx/src/components/Groups/Groups.test.js
new file mode 100644
--- /dev/null
+++ b/jsx/src/components/Groups/Groups.test.js
@@ -0,0 +1,65 @@
+import React from "react";
+import Enzyme, { mount } from "enzyme";
+import Groups from "./Groups";
+import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
+import { Provider, useDispatch, useSelector } from "react-redux";
+import { createStore } from "redux";
+import { HashRouter } from "react-router-dom";
+
+Enzyme.configure({ adapter: new Adapter() });
+
+jest.mock("react-redux", () => ({
+  ...jest.requireActual("react-redux"),
+  useSelector: jest.fn(),
+  useDispatch: jest.fn(),
+}));
+
+describe("Groups Component: ", () => {
+  var mockAsync = () =>
+    jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
+
+  var groupsJsx = (callbackSpy) => (
+    <Provider store={createStore(() => {}, {})}>
+      <HashRouter>
+        <Groups location={{ search: "0" }} updateGroups={callbackSpy} />
+      </HashRouter>
+    </Provider>
+  );
+
+  var mockAppState = () => ({
+    user_data: JSON.parse(
+      '[{"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":{}}]'
+    ),
+    groups_data: JSON.parse(
+      '[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
+    ),
+  });
+
+  beforeEach(() => {
+    useSelector.mockImplementation((callback) => {
+      return callback(mockAppState());
+    });
+    useDispatch.mockImplementation(() => {
+      return () => {};
+    });
+  });
+
+  afterEach(() => {
+    useSelector.mockClear();
+  });
+
+  it("Renders groups_data prop into links", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(groupsJsx(callbackSpy)),
+      links = component.find("li");
+    expect(links.length).toBe(2);
+  });
+
+  it("Renders nothing if required data is not available", () => {
+    useSelector.mockImplementation((callback) => {
+      return callback({});
+    });
+    let component = mount(groupsJsx());
+    expect(component.html()).toBe("<div></div>");
+  });
+});
diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.test.js b/jsx/src/components/ServerDashboard/ServerDashboard.test.js
new file mode 100644
--- /dev/null
+++ b/jsx/src/components/ServerDashboard/ServerDashboard.test.js
@@ -0,0 +1,161 @@
+import React from "react";
+import Enzyme, { mount } from "enzyme";
+import ServerDashboard from "./ServerDashboard";
+import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
+import { HashRouter, Switch } from "react-router-dom";
+import { Provider, useSelector } from "react-redux";
+import { createStore } from "redux";
+
+Enzyme.configure({ adapter: new Adapter() });
+
+jest.mock("react-redux", () => ({
+  ...jest.requireActual("react-redux"),
+  useSelector: jest.fn(),
+}));
+
+describe("ServerDashboard Component: ", () => {
+  var serverDashboardJsx = (callbackSpy) => (
+    <Provider store={createStore(() => {}, {})}>
+      <HashRouter>
+        <Switch>
+          <ServerDashboard
+            updateUsers={callbackSpy}
+            shutdownHub={callbackSpy}
+            startServer={callbackSpy}
+            stopServer={callbackSpy}
+            startAll={callbackSpy}
+            stopAll={callbackSpy}
+          />
+        </Switch>
+      </HashRouter>
+    </Provider>
+  );
+
+  var mockAsync = () =>
+    jest
+      .fn()
+      .mockImplementation(() =>
+        Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
+      );
+
+  var mockAppState = () => ({
+    user_data: JSON.parse(
+      '[{"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":{}}]'
+    ),
+  });
+
+  beforeEach(() => {
+    useSelector.mockImplementation((callback) => {
+      return callback(mockAppState());
+    });
+  });
+
+  afterEach(() => {
+    useSelector.mockClear();
+  });
+
+  it("Renders users from props.user_data into table", () => {
+    let component = mount(serverDashboardJsx(mockAsync())),
+      userRows = component.find(".user-row");
+    expect(userRows.length).toBe(2);
+  });
+
+  it("Renders correctly the status of a single-user server", () => {
+    let component = mount(serverDashboardJsx(mockAsync())),
+      userRows = component.find(".user-row");
+    // Renders .stop-button when server is started
+    // Should be 1 since user foo is started
+    expect(userRows.at(0).find(".stop-button").length).toBe(1);
+    // Renders .start-button when server is stopped
+    // Should be 1 since user bar is stopped
+    expect(userRows.at(1).find(".start-button").length).toBe(1);
+  });
+
+  it("Invokes the startServer event on button click", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(serverDashboardJsx(callbackSpy)),
+      startBtn = component.find(".start-button");
+    startBtn.simulate("click");
+    expect(callbackSpy).toHaveBeenCalled();
+  });
+
+  it("Invokes the stopServer event on button click", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(serverDashboardJsx(callbackSpy)),
+      stopBtn = component.find(".stop-button");
+    stopBtn.simulate("click");
+    expect(callbackSpy).toHaveBeenCalled();
+  });
+
+  it("Invokes the shutdownHub event on button click", () => {
+    let callbackSpy = mockAsync(),
+      component = mount(serverDashboardJsx(callbackSpy)),
+      shutdownBtn = component.find("#shutdown-button").first();
+    shutdownBtn.simulate("click");
+    expect(callbackSpy).toHaveBeenCalled();
+  });
+
+  it("Sorts according to username", () => {
+    let component = mount(serverDashboardJsx(mockAsync())).find(
+        "ServerDashboard"
+      ),
+      handler = component.find("SortHandler").first();
+    handler.simulate("click");
+    let first = component.find(".user-row").first();
+    expect(first.html().includes("bar")).toBe(true);
+    handler.simulate("click");
+    first = component.find(".user-row").first();
+    expect(first.html().includes("foo")).toBe(true);
+  });
+
+  it("Sorts according to admin", () => {
+    let component = mount(serverDashboardJsx(mockAsync())).find(
+        "ServerDashboard"
+      ),
+      handler = component.find("SortHandler").at(1);
+    handler.simulate("click");
+    let first = component.find(".user-row").first();
+    expect(first.html().includes("admin")).toBe(true);
+    handler.simulate("click");
+    first = component.find(".user-row").first();
+    expect(first.html().includes("admin")).toBe(false);
+  });
+
+  it("Sorts according to last activity", () => {
+    let component = mount(serverDashboardJsx(mockAsync())).find(
+        "ServerDashboard"
+      ),
+      handler = component.find("SortHandler").at(2);
+    handler.simulate("click");
+    let first = component.find(".user-row").first();
+    // foo used most recently
+    expect(first.html().includes("foo")).toBe(true);
+    handler.simulate("click");
+    first = component.find(".user-row").first();
+    // invert sort - bar used least recently
+    expect(first.html().includes("bar")).toBe(true);
+  });
+
+  it("Sorts according to server status (running/not running)", () => {
+    let component = mount(serverDashboardJsx(mockAsync())).find(
+        "ServerDashboard"
+      ),
+      handler = component.find("SortHandler").at(3);
+    handler.simulate("click");
+    let first = component.find(".user-row").first();
+    // foo running
+    expect(first.html().includes("foo")).toBe(true);
+    handler.simulate("click");
+    first = component.find(".user-row").first();
+    // invert sort - bar not running
+    expect(first.html().includes("bar")).toBe(true);
+  });
+
+  it("Renders nothing if required data is not available", () => {
+    useSelector.mockImplementation((callback) => {
+      return callback({});
+    });
+    let component = mount(serverDashboardJsx(jest.fn()));
+    expect(component.html()).toBe("<div></div>");
+  });
+});