← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~nickdv99/maas-site-manager:feat-create-login-dumb-copmonents-MAASENG-1449 into maas-site-manager:main

 

Nick De Villiers has proposed merging ~nickdv99/maas-site-manager:feat-create-login-dumb-copmonents-MAASENG-1449 into maas-site-manager:main.

Commit message:
feat: Create dumb login components

Requested reviews:
  MAAS Committers (maas-committers): code, qa

For more details, see:
https://code.launchpad.net/~nickdv99/maas-site-manager/+git/site-manager/+merge/440309

## Done
- Created LoginForm component (with tests)
- Created Login page and added to route
- Included VF card styles
- Added site header to Login page (like MAAS UI)

## QA

1. Go to `/login`
2. Ensure the page renders correctly across all breakpoints
3. Input some text into the Username and Password fields
4. Ensure the "Login" button is enabled
5. In `frontend/src/components/LoginForm/LoginForm.tsx`, add `console.log(values)` to the `handleSubmit` function
6. Open the console in your browser's dev tools
7. Click the "Login" button
8. Ensure the values you entered are logged to the console correctly.

## Fixes

Fixes https://warthogs.atlassian.net/browse/MAASENG-1449
-- 
Your team MAAS Committers is requested to review the proposed merge of ~nickdv99/maas-site-manager:feat-create-login-dumb-copmonents-MAASENG-1449 into maas-site-manager:main.
diff --git a/frontend/src/App.scss b/frontend/src/App.scss
index 7948b24..2d7748f 100644
--- a/frontend/src/App.scss
+++ b/frontend/src/App.scss
@@ -22,6 +22,7 @@
 @include vf-p-contextual-menu;
 @include vf-p-form-validation;
 @include vf-p-notification;
+@include vf-p-card;
 
 // icons
 @include vf-p-icons;
diff --git a/frontend/src/components/LoginForm/LoginForm.test.tsx b/frontend/src/components/LoginForm/LoginForm.test.tsx
new file mode 100644
index 0000000..6c0e35f
--- /dev/null
+++ b/frontend/src/components/LoginForm/LoginForm.test.tsx
@@ -0,0 +1,55 @@
+import LoginForm from "./LoginForm";
+
+import { render, screen, userEvent } from "@/test-utils";
+
+describe("LoginForm", () => {
+  it("renders", () => {
+    render(<LoginForm />);
+
+    expect(screen.getByRole("form", { name: "Login" })).toBeInTheDocument();
+  });
+
+  it("displays an error if the username input is left empty", async () => {
+    render(<LoginForm />);
+
+    const usernameInput = screen.getByRole("textbox", { name: "Username" });
+
+    await userEvent.type(usernameInput, "test");
+    await userEvent.clear(usernameInput);
+    await userEvent.click(screen.getByRole("form"));
+
+    expect(screen.getByText(/Please enter a username/)).toBeInTheDocument();
+  });
+
+  it("displays an error if the password input is left empty", async () => {
+    render(<LoginForm />);
+
+    const passwordInput = screen.getByLabelText("Password");
+
+    await userEvent.type(passwordInput, "test");
+    await userEvent.clear(passwordInput);
+    await userEvent.click(screen.getByRole("form"));
+
+    expect(screen.getByText(/Please enter a password/)).toBeInTheDocument();
+  });
+
+  it("disables the 'Login' button if a username and password are not present", async () => {
+    render(<LoginForm />);
+
+    const usernameInput = screen.getByRole("textbox", { name: "Username" });
+    const passwordInput = screen.getByLabelText("Password");
+    const loginButton = screen.getByRole("button", { name: "Login" });
+
+    expect(loginButton).toBeDisabled();
+
+    await userEvent.type(usernameInput, "uname");
+    expect(loginButton).toBeDisabled();
+
+    await userEvent.clear(usernameInput);
+    await userEvent.type(passwordInput, "pword");
+    expect(loginButton).toBeDisabled();
+
+    await userEvent.type(usernameInput, "uname");
+    expect(loginButton).not.toBeDisabled();
+  });
+});
diff --git a/frontend/src/components/LoginForm/LoginForm.tsx b/frontend/src/components/LoginForm/LoginForm.tsx
new file mode 100644
index 0000000..34068fa
--- /dev/null
+++ b/frontend/src/components/LoginForm/LoginForm.tsx
@@ -0,0 +1,75 @@
+import { Col, Row, Strip, Input, useId, Label, Card, Button } from "@canonical/react-components";
+import { Field, Form, Formik } from "formik";
+import * as Yup from "yup";
+
+const initialValues = {
+  username: "",
+  password: "",
+};
+
+type LoginFormValues = typeof initialValues;
+
+const LoginFormSchema = Yup.object().shape({
+  username: Yup.string().required("Please enter a username."),
+  password: Yup.string().required("Please enter a password."),
+});
+
+const LoginForm = () => {
+  const id = useId();
+  const headingId = `heading-${id}`;
+  const usernameId = `username-${id}`;
+  const passwordId = `password=${id}`;
+
+  const handleSubmit = (values: LoginFormValues) => {
+    // 1. send values to backend
+    // 2. if error, return error and display
+    // 3. if all good, set cookie and navigate to /sites
+  };
+
+  return (
+    <Strip>
+      <Row>
+        <Col emptyLarge={4} size={6}>
+          <Card>
+            <h1 className="p-card__title p-heading--3" id={headingId}>
+              Login
+            </h1>
+            <Formik<LoginFormValues>
+              initialValues={initialValues}
+              onSubmit={handleSubmit}
+              validationSchema={LoginFormSchema}
+            >
+              {({ isSubmitting, errors, touched, isValid, dirty }) => (
+                <Form aria-labelledby={headingId}>
+                  <Label htmlFor={usernameId}>Username</Label>
+                  <Field
+                    as={Input}
+                    error={touched.username && errors.username}
+                    id={usernameId}
+                    name="username"
+                    required
+                    type="text"
+                  />
+                  <Label htmlFor={passwordId}>Password</Label>
+                  <Field
+                    as={Input}
+                    error={touched.password && errors.password}
+                    id={passwordId}
+                    name="password"
+                    required
+                    type="password"
+                  />
+                  <Button appearance="positive" disabled={!dirty || !isValid || isSubmitting} type="submit">
+                    Login
+                  </Button>
+                </Form>
+              )}
+            </Formik>
+          </Card>
+        </Col>
+      </Row>
+    </Strip>
+  );
+};
+
+export default LoginForm;
diff --git a/frontend/src/components/LoginForm/index.ts b/frontend/src/components/LoginForm/index.ts
new file mode 100644
index 0000000..8059f00
--- /dev/null
+++ b/frontend/src/components/LoginForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./LoginForm";
diff --git a/frontend/src/components/Navigation/_Navigation.scss b/frontend/src/components/Navigation/_Navigation.scss
index a4c4d5f..706e28d 100644
--- a/frontend/src/components/Navigation/_Navigation.scss
+++ b/frontend/src/components/Navigation/_Navigation.scss
@@ -115,3 +115,11 @@
 .p-side-navigation--tooltip {
   z-index: $side-navigation-z-index + 1;
 }
+
+.l-navigation-bar.is-pinned {
+  width: 100%;
+  display: block;
+  visibility: visible;
+  grid-area: navbar;
+  height: 3.5rem;
+}
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index a3c2fab..67e9b12 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -1,7 +1,29 @@
-const Login: React.FC = () => (
-  <section>
-    <h2>Login</h2>
-  </section>
-);
+import { Col, Strip } from "@canonical/react-components";
+
+import LoginForm from "@/components/LoginForm";
+import NavigationBanner from "@/components/Navigation/NavigationBanner";
+
+const Login: React.FC = () => {
+  return (
+    <div className="l-application">
+      <header className="l-navigation-bar is-pinned">
+        <div className="p-panel is-dark">
+          <div className="p-panel__header">
+            <NavigationBanner />
+          </div>
+        </div>
+      </header>
+      <main className="l-main">
+        <div>
+          <Strip element="section" includeCol={false} shallow>
+            <Col size={12}>
+              <LoginForm />
+            </Col>
+          </Strip>
+        </div>
+      </main>
+    </div>
+  );
+};
 
 export default Login;
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index b9ebdc8..acdc823 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -1,24 +1,28 @@
 import { createRoutesFromElements, Route, redirect } from "react-router-dom";
 
 import MainLayout from "@/components/MainLayout";
+import Login from "@/pages/login";
 import Requests from "@/pages/requests";
 import SitesList from "@/pages/sites";
 import Tokens from "@/pages/tokens/tokens";
 
 export const routes = createRoutesFromElements(
-  <Route element={<MainLayout />} path="/">
-    {/*
+  <>
+    <Route element={<Login />} path="/login" />
+    <Route element={<MainLayout />} path="/">
+      {/*
           TODO: redirect to /login when unauthenticated
           https://warthogs.atlassian.net/browse/MAASENG-1450
       */}
-    <Route index loader={() => redirect("sites")} />
-    <Route path="login" />
-    <Route path="logout" />
-    <Route element={<SitesList />} path="sites" />
-    <Route element={<Requests />} path="requests" />
-    <Route element={<Tokens />} path="tokens" />
-    <Route path="users" />
-  </Route>,
+      <Route index loader={() => redirect("sites")} />
+      <Route path="logout" />
+      <Route element={<SitesList />} path="sites" />
+      <Route element={<Requests />} path="requests" />
+      <Route element={<Tokens />} path="tokens" />
+      <Route path="users" />
+    </Route>
+    ,
+  </>,
 );
 
 export default routes;

Follow ups