DEV Community

loading...
Cover image for AMP CMS: Dashboard

AMP CMS: Dashboard

Valeria
Re-inventor extraordinaire, node/js lover, optimisation addict and open web advocate
・14 min read

Initially, I was planning to use AMP to build the dashboard as well. But, with great power ...comes a lot of restrictions. AMP is simply not made for heavy coding, even though I've managed to render couple of rich editors in frames.

In my daylife I use React to build control panels, but my developer's heart belongs to Preact and naturally, that was my next choice.

Setting up the development enviroment

For this project, I've decided to give fuse-box a try and loved it. It's a very fast web bundler based on TypeScript. The only drawback was to install node-sass instead of the dart sass version I already have. Oh, well, it's just one useless module.

TL;DR; I'm bundling jsx & scss and serving the results along with the API. You can peek at the actual setup on the GitHub repo if you're curious:

GitHub logo ValeriaVG / amp-cms

Content management system for blazingly fast AMP websites, written in TypeScript and powered by Redis

The idea

I want CMS users to be able to:

  • manage pages, their content, SEO, and appearance
  • edit styles and pages templates

AMP has a lot of restrictions and a generic WYSIWYG HTML editor is out of the question, hence I've decided to go with markdown.

Additionally, we can use meta markdown like this:

---
title: "Hello"
slug: home
---
<h1>Hello world!</h1>
Enter fullscreen mode Exit fullscreen mode

Curtesy of gray-matter package:

GitHub logo jonschlinkert / gray-matter

Smarter YAML front matter parser, used by metalsmith, Gatsby, Netlify, Assemble, mapbox-gl, phenomic, and many others. Simple to use, and battle tested. Parses YAML by default but can also parse JSON Front Matter, Coffee Front Matter, TOML Front Matter, and has support for custom parsers.

Color palette

I've started with a theme.scss file, describing some basic variables and settings:

// Lightest color
$light: #ffffff;
// Darkest color
$dark: #3e464c;
// Accents
$primary: #ff52e1;
$secondary: #5500d7;
$neutral: #9e9e9e;
// States
$danger: #f44336;
$positive: #4caf50;
$info: #03a9f4;

// Typo
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap");
$font-family: "Noto Sans", sans-serif;
body {
  font-size: 16px;
  line-height: 1.2;
  font-family: $font-family;
}
Enter fullscreen mode Exit fullscreen mode

Basic layout

Every page except for login will be rendered with the same header, sidebar, and footer:

Dark header on the top, dark footer on the bottom, darker sidebar on the left

Styles are based on css grid and material design concepts:

@import "theme";

body,
html {
  margin: 0;
}

body {
  // Fullscreen
  height: 100vh;
  width: 100vw;
  // Grid layout
  display: grid;
  grid-template-areas:
    "header header"
    "sidebar main"
    "sidebar footer";
  grid-template-rows: 3rem 1fr 2rem;
  grid-template-columns: 3rem 1fr;

  & > * {
    box-sizing: border-box;
    position: relative;
  }

  & > header {
    grid-area: header;
    background-color: $dark;
    color: $light;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
    z-index: 2;
  }

  & > aside {
    grid-area: sidebar;
    background-color: darken($dark, 15%);
    color: $light;
    box-shadow: 1px 0 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
    z-index: 1;
  }

  & > main {
    grid-area: main;
    background-color: darken($light, 5%);
  }

  & > footer {
    padding: 0.5rem 1rem;
    grid-area: footer;
    background-color: $dark;
    color: fade-out($light, 0.5);
    font-size: 0.8rem;
    text-align: right;
  }
}

Enter fullscreen mode Exit fullscreen mode

And Preact element markup:

import * as Preact from "preact";

export default function App() {
  return (
    <>
      <header></header>
      <aside></aside>
      <main></main>
      <footer>AMP CMS v0.0.1</footer>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

Cards

As material design proposes, all elements should be rendered as if they were material things, e.g. paper card or a metal plate. Hence, the main background is slightly darker than the $light color, creating a nice base for an underlying surface.

The elements, we will be rendering atop of it should be slightly raised using box-shadow and be lighter than the background:

Paper-like white section in the main part

I've styled it like this:

 & > main {
    grid-area: main;
    background-color: darken($light, 5%);
    color: $dark;
    padding: 1rem;
    & > section {
      background-color: $light;
      @include laying-flat(top);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Where laying-flat is a mixin with box-shadow I've extracted:

@mixin laying-flat($position) {
  $x: 0;
  $y: 1px;

  [position="left"] & {
    $x: 1px;
    $y: 0;
  }

  box-shadow: $x $y 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
Enter fullscreen mode Exit fullscreen mode

Adding icons

FontAwesome icons have a set of components for React, but it doesn't work with Preact out of the box. I could use preact/compat, but @fortawesome/react-fontawesome requires prop-types, which in its turn wants react-is. Adding all this zoo to use one component was out of the question so I wrote Preact FontAwesomeIcon.

Now the main layout got a shiny new icon:

import * as Preact from "preact";
import { faBars } from "@fortawesome/free-solid-svg-icons";
import FontAwesomeIcon from "./FontAwesomeIcon";

export default function App() {
  return (
    <>
      <header>
        <button>
          <FontAwesomeIcon icon={faBars} />
        </button>
      </header>
      <aside></aside>
      <main>
        <section style={{ padding: "1rem", height: 200 }}></section>
      </main>
      <footer>AMP CMS v0.0.1</footer>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

I've created a mixin for buttons and reset button tag styles to:

@mixin button {
  border: none;
  padding: 0.5em 1em;
  background: transparent;
  font-family: inherit;
  font-size: inherit;
  color: inherit;
}

button,
.button {
  @include button;
}
Enter fullscreen mode Exit fullscreen mode

In a similar manner, I've added the main dashboard buttons to the sidebar and got this:

Menu button in the header and sidebar buttons: home, users, styles, and templates

Adding pages

Preact has its own preact-router, but It is very basic and doesn't support routing from a subfolder. Fortunately, react-router-dom supports it and with the help of preact/compat works with Preact as well.

After a bit of refactoring, my App looked like this:

import * as Preact from "preact";
import { Route, BrowserRouter, Switch } from "react-router-dom";
import { dashboard } from "config";
import NotFound from "./pages/NotFound";
import Home from "./pages/home";
import Layout from "./Layout";

export default function App() {
  return (
    <BrowserRouter basename={dashboard.path}>
      <Layout>
        <Switch>
          <Route path="/" exact component={Home} />
          <Route path="*" component={NotFound} />
        </Switch>
      </Layout>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the layout, now in a separate file looked like this:

import * as Preact from "preact";
import {
  faBars,
  faColumns,
  faHome,
  faPalette,
  faUsers,
} from "@fortawesome/free-solid-svg-icons";
import { NavLink } from "react-router-dom";
import FontAwesomeIcon from "./utils/FontAwesomeIcon";

export default function Layout({
  children,
}: {
  children: Preact.ComponentChildren;
}) {
  return (
    <>
      <header>
        <button>
          <FontAwesomeIcon icon={faBars} />
        </button>
      </header>
      <aside>
        <nav>
          <ul class="menu">
            <li>
              <NavLink to="/" exact>
                <FontAwesomeIcon icon={faHome} />
              </NavLink>
            </li>
            <li>
              <NavLink to="/users">
                <FontAwesomeIcon icon={faUsers} />
              </NavLink>
            </li>
            <li>
              <NavLink to="/styles">
                <FontAwesomeIcon icon={faPalette} />
              </NavLink>
            </li>
            <li>
              <NavLink to="/templates">
                <FontAwesomeIcon icon={faColumns} />
              </NavLink>
            </li>
          </ul>
        </nav>
      </aside>
      <main>{children}</main>
      <footer>AMP CMS v0.0.1</footer>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

The dashboard finally starting to look like one:

Page routing works and menu changes state

Adding editors

Arguably the most challenging part of every single development is integrating with third-party software. You never know what can go wrong before it does, but a good package can save a lot of time. I've planned not one, but two full-featured editors for the dashboard, implementing which would be a pain.

After trials and errors, I've decided to use monaco-editor for all the editing purposes.

Adding it was super easy:

import * as Preact from "preact";
import * as monaco from "monaco-editor";

export default class Editor extends Preact.Component<
  {
    value?: string;
    onChange?: (value: string) => any;
  } & Preact.JSX.HTMLAttributes<HTMLDivElement>
> {
  ref = Preact.createRef();
  editor;
  componentDidMount() {
    this.editor = monaco.editor.create(this.ref.current, {
      value: this.props.value,
      language: "markdown",
      lineNumbers: "off",
      minimap: { enabled: false },
    });
    this.editor.onDidChangeModelContent(() => {
      const value = this.editor.getValue();
      console.log(value);
      this.props.onChange && this.props.onChange(value);
    });
    window.onresize = this.onResize;
  }
  componentWillUnmount() {
    delete window.onresize;
  }
  onResize = () => {
    this.editor.layout();
  };
  render() {
    const { value, onChange, style, ...props } = this.props;
    return (
      <div
        ref={this.ref}
        style={Object.assign({ width: "100%", minHeight: 300 }, style)}
        {...props}
      />
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Here's how it looked like:
Markdown editor rendering markdown like a pro

API & Authorization

Remember the ugly AMP Login page we made in previous articles? We're still going to use it! Unauthorized users will see AMP login page, that will handle assigning access tokens, with not a bit of the dashboard.

When the dashboard page was requested with proper credentials middleware is going to render preact dashboard instead.

And the API then can be called with common fetch requests, that I wrapped into a simple library:

import { HTTPMethod, JSONObject } from "core/types";

export class Api {
  constructor(public host: string = "") {}

  fetch = typeof window !== "undefined" && window.fetch;

  call = (path: string, method: HTTPMethod = "GET", input?: JSONObject) =>
    this.fetch(this.host + path, {
      method,
      mode: "cors",
      body: input && JSON.stringify(input),
      headers: {
        "Content-Type": "application/json",
      },
      credentials: "include",
    }).then(async (res) => {
      let response;
      try {
        response = await res.json();
      } catch (error) {
        response = {
          code: res.status,
          errors: [
            {
              name: error.name,
              message: error.message,
            },
          ],
        };
      }
      return response;
    });

  get = (path: string) => this.call(path);
  post = (path: string, input: JSONObject) => this.call(path, "POST", input);
  patch = (path: string, input: JSONObject) => this.call(path, "PATCH", input);
  delete = (path: string) => this.call(path);
}
export default new Api();

Enter fullscreen mode Exit fullscreen mode

You can see how it can be used in this test:

import { describe, it } from "mocha";
import { expect } from "chai";
import { Api } from "./api";
import fetch from "node-fetch";

describe("utils.api", () => {
  const api = new Api("https://jsonplaceholder.typicode.com");
  api.fetch = fetch as any;
  it("can send get requests", async () => {
    expect(await api.get("/todos/1")).to.have.property("id", 1);
  });
  it("can send post requests", async () => {
    expect(await api.post("/posts", { title: "test" })).to.have.property(
      "title",
      "test"
    );
  });
});

Enter fullscreen mode Exit fullscreen mode

Login page

I've styled up the login page to look like this:
AMP CMS Login page with AMP gradient

The shortened markup looks like this:

<main class="wrapper">
    <amp-layout layout="intrinsic" width="320" height="320" style="margin:auto">
     <amp-layout amp-access="NOT canAccessDashboard" amp-access-hide>
        <form method="post" action-xhr="/login" target="_top">
          <fieldset>
            <label>
              <span>Email:</span>
              <input type="email" name="email" required autocomplete="email" />
            </label>
            <br />
            <label>
              <span>Password:</span>
              <input
                type="password"
                name="password"
                required
                autocomplete="current-password"
              />
            </label>

            <br />
            <input type="submit" value="Login" />
          </fieldset>
          <div class="result">
            <div submit-success class="success">
              <template type="amp-mustache">
                {{#canAccessDashboard}}
                <div>
                  <a
                    href="${dashboard.path}"
                    class="button"
                    style="border-radius:4px;"
                    >Access dashboard</a
                  >
                </div>
                {{/canAccessDashboard}} {{^canAccessDashboard}} You shall not
                pass! 🧐 {{/canAccessDashboard}}
              </template>
            </div>
            <div submit-error class="error">
              <template type="amp-mustache">
                Incorrect email or password
              </template>
            </div>
          </div>
        </form>
      </amp-layout>
    </amp-layout>
  </main>
Enter fullscreen mode Exit fullscreen mode

I'm using amp-access here only to trigger setting up cookies. And once the login operation succeeds it shows a link user needs to press. Yeah, you can't just reload the page in amp, because amp runs custom javascript in a web worker.

Normally, on public amp pages, you would display a placeholder with "Log in" button which would trigger a popup with login, which doesn't fit our use case at all.

When a user logs in successfully, they see the "Enter dashboard" button:

Enter Dashboard

After that, as long as the user has the valid token, he will see the dashboard right away.

Form & List pages

I've made two generic pages. A form page:

"New page" with breadcrumbs, save button, markdown editor, template selector, and publication date input

import { faSave } from "@fortawesome/free-solid-svg-icons";
import BreadCrumbs from "dashboard/components/BreadCrumbs";
import api from "dashboard/utils/api";
import FontAwesomeIcon from "dashboard/utils/FontAwesomeIcon";
import useForm, { FormValues } from "dashboard/utils/useForm";
import * as Preact from "preact";
import { Link } from "react-router-dom";

export default function ItemForm<T extends Record<string, any>>({
  path,
  singular,
  plural,
  defaultValue,
  children,
}: {
  singular: string;
  plural: string;
  path: string;
  defaultValue?: Partial<T>;
  children: (form: FormValues<Partial<T>>) => Preact.JSX.Element;
}) {
  const form = useForm<Partial<T>>(defaultValue);
  const onSubmit = async (e) => {
    e.preventDefault();
    const result = await api.post(path, form.values);
    console.log(result);
  };
  const label = `New ${singular}`;
  return (
    <form onSubmit={onSubmit}>
      <header>
        <div>
          <BreadCrumbs
            path={[
              {
                to: path,
                label: plural,
              },
              {
                label,
              },
            ]}
          />
          <h1>{label}</h1>
        </div>
        <div class="buttons">
          <Link to={path} className="button-secondary" type="cancel">
            Cancel
          </Link>
          <button class="button-primary" type="submit">
            <FontAwesomeIcon icon={faSave} />
            Save
          </button>
        </div>
      </header>
      {children(form)}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

And a listing page:
Empty table with a "New user" button

import { faPlus } from "@fortawesome/free-solid-svg-icons";
import Table, { TableColumns } from "dashboard/components/Table";
import FontAwesomeIcon from "dashboard/utils/FontAwesomeIcon";
import useQuery from "dashboard/utils/useQuery";
import * as Preact from "preact";
import { Link } from "react-router-dom";

export default function ItemsList<T extends { id: string }>({
  singular,
  plural,
  path,
  columns,
}: {
  singular: string;
  columns: TableColumns<T>;
  path: string;
  plural: string;
}) {
  const { result, loading } = useQuery<{ items: T[] }>(path);
  const items = result && "items" in result ? result.items : [];
  return (
    <>
      <header>
        <h1>{plural}</h1>
        <Link to={`${path}/new`} className="button-primary">
          <FontAwesomeIcon icon={faPlus} />
          New {singular}
        </Link>
      </header>
      <section>
        <Table
          keyField="id"
          items={items}
          isLoading={loading}
          columns={{
            ...columns,
            edit: {
              render: ({ id }) => (
                <Link to={`${path}/${id}`} className="button-secondary">
                  Edit
                </Link>
              ),
            },
          }}
        />
      </section>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

I've added a generic routing page:

import * as Preact from "preact";
import { Route, Switch } from "react-router-dom";
import { TableColumns } from "./components/Table";
import ItemForm from "./ItemForm";
import ItemsList from "./ItemsList";
import { FormValues } from "./utils/useForm";

export default function ItemRoutes<T extends { id: string }>({
  name,
  columns,
  renderForm,
  defaultValue,
  ...props
}: {
  name: string;
  columns: TableColumns<T>;
  plural?: string;
  path?: string;
  defaultValue?: Partial<T>;
  renderForm: (form: FormValues<Partial<T>>) => Preact.JSX.Element;
}) {
  const plural = props.plural ?? name + "s";
  const path = props.path ?? "/" + plural.toLowerCase();
  const params = { singular: name, plural, path };
  const Page = () => (
    <ItemForm {...params} defaultValue={defaultValue}>
      {renderForm}
    </ItemForm>
  );
  const Pages = () => <ItemsList {...params} columns={columns} />;
  return (
    <Switch>
      <Route path={`${path}/:new`} exact render={Page} />
      <Route path={`${path}/:new`} exact render={Page} />
      <Route path={path} exact render={Pages} />
    </Switch>
  );
}

Enter fullscreen mode Exit fullscreen mode

And then used it to create all the pages for users, templates, styles, and pages like this:

import Editor from "dashboard/components/Editor";
import ItemRoutes from "dashboard/ItemRoutes";
import { StyleData } from "modules/styles/Styles";
import * as Preact from "preact";

export default function Styles() {
  return (
    <ItemRoutes<StyleData>
      name="Style"
      columns={{
        id: { label: "ID" },
        templates: {
          label: "Templates",
        },
      }}
      defaultValue={{
        id: "template-style",
        data: `.template-class{\n\n}`,
      }}
      renderForm={({ values, setValue, onValueChange }) => (
        <>
          <fieldset style="max-width:320px;margin-right:auto;">
            <label>
              ID:
              <input
                type="text"
                name="id"
                value={values.id}
                onChange={onValueChange("id")}
              />
            </label>
          </fieldset>

          <fieldset>
            <Editor
              theme="vs-dark"
              value={values.data}
              language="css"
              onChange={setValue("data")}
            />
          </fieldset>
        </>
      )}
    />
  );
}

Enter fullscreen mode Exit fullscreen mode

The Table and Breadcrumbs components and useForm hook I carry from a project to another, adjusting as needed. For this one, I've needed to make them Preact - compatible, for example. Which won't be possible if they would be in a package.

The useQuery hook was inspired by Apollo GraphQL client, but made to be used with REST API:

import { ErrorResponse, JSONResponse, SimpleTypes } from "core/types";
import { useState, useEffect } from "preact/hooks";
import api from "./api";
export default function useQuery<T>(
  path: string,
  params?: Record<string, SimpleTypes>
) {
  const [state, setState] = useState<{
    result?: JSONResponse<T> | ErrorResponse;
    loading: boolean;
  }>({
    result: null,
    loading: true,
  });
  const query = params
    ? "?" +
      Object.keys(params)
        .map((key) => `${key}=${params[key]}`)
        .join("&")
    : "";
  useEffect(() => {
    setState({ loading: true });
    api
      .get(path + query)
      .then((result) => setState({ result, loading: false }));
  }, [path, query]);

  return state;
}

Enter fullscreen mode Exit fullscreen mode

Analytics page

The home page was made using recharts:

import * as Preact from "preact";
import {
  LineChart,
  XAxis,
  Tooltip,
  CartesianGrid,
  Line,
  ResponsiveContainer,
  YAxis,
} from "recharts";

const data: { date: number; visitors: number }[] = [
  {
    date: new Date("2020-12-01").getTime(),
    visitors: 0,
  },
  {
    date: new Date("2020-12-05").getTime(),
    visitors: 100,
  },
  {
    date: new Date("2020-12-11").getTime(),
    visitors: 54,
  },
  {
    date: new Date("2020-11-23").getTime(),
    visitors: 24,
  },
  {
    date: new Date("2020-12-25").getTime(),
    visitors: 10,
  },
  {
    date: new Date("2021-01-01").getTime(),
    visitors: 150,
  },
];

const months = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

export default function Home(): any {
  return (
    <>
      <h1>Website name</h1>
      <section style="padding:2rem 2rem 2rem 0;">
        <ResponsiveContainer width={"100%"} minHeight={260}>
          {/* @ts-ignore */}
          <LineChart data={data}>
            <XAxis
              dataKey="date"
              padding={{ left: 40, right: 40 }}
              tickLine={false}
              axisLine={false}
              tickFormatter={(ts: number) => {
                const date = new Date(ts);
                return `${
                  months[date.getMonth()]
                } ${date.getDate()}, ${date.getFullYear()}`;
              }}
            />
            <YAxis
              type="number"
              tickLine={false}
              axisLine={false}
              tickFormatter={(visitors: number) =>
                (visitors ? visitors : "") as any
              }
            />
            <Tooltip />
            <CartesianGrid vertical={false} />
            <Line
              type="monotone"
              dataKey="visitors"
              yAxisId={0}
              strokeWidth={2}
              color="#03a9f4"
            />
          </LineChart>
        </ResponsiveContainer>
      </section>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

Here's how it looked like:

Home page with line graph

Recharts have a lot of graphs and we can use any of them. I've skipped deciding till I get some analytical data to represent here.

Logout

Mostly I use popups for the logout action, but this time I've decided to go for a much simpler and mobile-friendly option: a logout page.

For now, it works as a confirmation window, but in the future, we can add Other functionality there, as view existing sessions and logout from them as well.

Here's the code:

import api from "dashboard/utils/api";
import * as Preact from "preact";
import { useHistory } from "react-router-dom";

export default function Logout() {
  const onSubmit = (e) => {
    e.preventDefault();
    api.put("/logout").then(() => (document.location.href = "/admin"));
  };
  const history = useHistory();
  return (
    <>
      <header>
        <h1>Logout</h1>
      </header>
      <form onSubmit={onSubmit}>
        <label style="padding:0;margin:1rem 0">
          Are you sure you want to logout?
        </label>
        <div class="buttons">
          <button
            class="button-alt"
            type="cancel"
            onClick={(e) => {
              e.preventDefault();
              history.goBack();
            }}
          >
            Cancel
          </button>
          <button class="button-secondary" type="submit">
            Log out
          </button>
        </div>
      </form>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

And log out in action:

Logout "Cancel" button goes to the previous page, "Log out" button logs the user out and redirects to the login page

Finishing touches

Design-wise, there are three things missing:

  • error and successful operations are not indicated in any way
  • menu button does nothing
  • there are no tests 😳

Notifications

In order to solve the first problem, I've decided to add popup notifications. It's a good way to show when something went horribly wrong or everything was fine and we're letting the user know about it. Or some other interesting event happens.

The idea is to have a context that will store those notifications and render them. Each notification has an expiration time and will automatically disappear unless explicitly removed from the context:

import { createContext, JSX } from "preact";
import { useContext, StateUpdater } from "preact/hooks";

export type Notification = {
  id: string;
  variant: "success" | "warning" | "error" | "info";
  content: string | JSX.Element;
  ttl?: number;
};

export const NotificationContext = createContext<{
  notifications: Notification[];
  setNotifications: StateUpdater<Array<Notification>>;
}>({ notifications: [], setNotifications: () => {} });

export default function useNotification() {
  const { setNotifications } = useContext(NotificationContext);
  const remove = (id: string) =>
    setNotifications((notifications) =>
      notifications.filter((a) => a.id !== id)
    );
  const add = (notification: Notification) => {
    setNotifications((notifications) => [notification, ...notifications]);
  };

  const showErrors = (errors: { name: string; message: string }[]) =>
    setNotifications((notifications) => [
      ...errors.map(({ name, message }) => ({
        id: name + "_" + notifications.length,
        content: message,
        variant: "error" as const,
        ttl: 10,
      })),
      ...notifications,
    ]);
  return { add, remove, showErrors };
}

Enter fullscreen mode Exit fullscreen mode

And add it to the App.tsx:

export default function App() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  return (
    <NotificationContext.Provider value={{ notifications, setNotifications }}>
      <BrowserRouter basename={dashboard.path}>
        <Layout>
          <Switch>
            <Route path="/" exact component={Home} />
            <Route path="/pages" component={Pages} />
            <Route path="/users" component={Users} />
            <Route path="/templates" component={Templates} />
            <Route path="/styles" component={Styles} />
            <Route path="/logout" component={Logout} />
            <Route path="*" component={NotFound} />
          </Switch>
        </Layout>
      </BrowserRouter>
    </NotificationContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we need to display them somewhere in the Layout.tsx:

<main>
        <div class="notifications">
          {notifications.map((notification) => (
            <NotificationElement key={notification.id} {...notification} />
          ))}
        </div>
        {children}
</main>
Enter fullscreen mode Exit fullscreen mode

Where NotificationElement is a self-destroying notification block with a nice progress bar:

import { faTimes } from "@fortawesome/free-solid-svg-icons";
import FontAwesomeIcon from "dashboard/utils/FontAwesomeIcon";
import * as Preact from "preact";
import { useEffect, useState } from "preact/hooks";
import useNotification, { Notification } from "../utils/notifications";
import "./notifications.scss";

export default function NotificationElement({
  id,
  content,
  variant,
  ttl,
}: Notification) {
  const [timeLeft, updateTimeLeft] = useState(ttl);
  const { remove } = useNotification();
  useEffect(() => {
    if (!ttl) return;
    const timer = setInterval(() => {
      updateTimeLeft((t) => {
        t += -0.1;
        if (t <= 0) remove(id);
        return t;
      });
    }, 100);
    return () => clearInterval(timer);
  }, [ttl]);

  return (
    <div
      class={`notification notification-${variant}`}
      onClick={() => remove(id)}
    >
      <span style="position:absolute;top:0.25rem;right:0.5rem;opacity:0.5">
        <FontAwesomeIcon icon={faTimes} size="xs" aria-label="Close" />
      </span>
      <meta name="ttl-seconds" value={timeLeft.toFixed(0)} />
      <div class="notification--progress">
        <div
          class="notification--progress--value"
          style={{ width: `${(timeLeft / ttl) * 100}%` }}
        />
      </div>
      <div class="notification--content">{content}</div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Style in sass:

@import "../theme";

@mixin notification($background, $color) {
  display: inline-flex;
  flex-direction: column;
  position: relative;
  overflow: hidden;
  border-radius: 2px;
  background: $background;
  color: $color;
  min-width: 240px;
  @include laying-flat(top);
  .notification--progress {
    position: absolute;
    bottom: 0;
    background: fade-out($color, 0.75);
    width: 100%;
    height: 3px;
    display: flex;
    &--value {
      background: darken($background, 5%);
      height: 100%;
      transition: width 100ms ease;
    }
  }
  .notification--content {
    padding: 1rem;
  }
}

.notification-error {
  @include notification($danger, $light);
}

.notification-success {
  @include notification($positive, $light);
}
.notification-info {
  @include notification($info, $light);
}


Enter fullscreen mode Exit fullscreen mode

This is how it looks like:
Red error notification in the bottom right with a close button and "Not found" error

Adding expandable menu

I've added menu titles in span to layout:

 <nav class="bottom-nav">
          <ul class="menu">
            <li>
              <NavLink to="/logout">
                <FontAwesomeIcon icon={faSignOutAlt} />
                <span>Log out</span>
              </NavLink>
            </li>
        </ul>
  </nav>
Enter fullscreen mode Exit fullscreen mode

And toggle to the button:

 const [isExpanded, setIsExpanded] = useState<boolean>(false);
  const toggleMenu = () => setIsExpanded((t) => !t);
  return (
    <>
      <header>
        <button onClick={toggleMenu}>
          <FontAwesomeIcon icon={faBars} />
        </button>
      </header>
{/* ... */}
    </>)
}
Enter fullscreen mode Exit fullscreen mode

I set the initial opacity of those span elements to zero, along with white-space: nowrap to keep them hidden, and added the .expanded class to sidebar styling:

& > aside {
    // ...
    &.expanded {
      width: max-content;

      .menu {
        & > li > a {
          span {
            opacity: 1;
          }
        }
      }
    }
  }
// ...
}
Enter fullscreen mode Exit fullscreen mode

And that's how it looked:
Expanding and collapsing menu

Testing

Writing unit tests for front-end components are different from writing unit tests for common functions. In most cases, you want to test that component doesn't crash on different inputs and actions. Unlike backend unit tests, I prefer to do those after the implementation because (P)react props depend on how the component will be used and what properties it needs to accept, and what to pass further.

Because this dashboard is very simple, I'm going to skip unit tests for now and implement end-to-end tests for the whole system once all the pieces are connected.

Which, I'm going to do in the next article, along with deploying the first-ever AMP CMS project to Digital Ocean App Platform!

Discussion (0)