sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #06722
[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