Skip to content
This repository has been archived by the owner on Oct 26, 2023. It is now read-only.

Latest commit

 

History

History
616 lines (451 loc) · 26.1 KB

README.md

File metadata and controls

616 lines (451 loc) · 26.1 KB

Lab - Standardised Web Client

Deprecation Warning: This project has been of immense value to discover best practices around React, with stacks like Remix becoming the way of building React apps, which offers consolidation of many of the plugins in use and cutting down a lot of edge case handling, it makes sense for us to move to a framework like Remix.

Remix offers a full stack approach and takes advantage of Server Side Rendering, improving performance of the client side applications. We still believe in the value of an API and decoupling the front end from the server side logic. The approach allows us to move away from Remix if it appeared to be the right decision to make or support native mobile clients for our applications.

The result is the psychedelic-stack which maintains our Python foundation on the server and uses Remix in a Backend for your Frontend approach.

Most of what have learnt by building this client will be migrated or ported as appropriate.

Objectives:

  • Typescript based Create React App base project
  • Validated SSL without any other dependencies
  • Internationalization support
  • Tailwind CSS based theming support
  • Header <head> management for usability using Helmet
  • Meeting W3C AAA Accessibility
  • Proxy API from Docker container without any other dependencies
  • Establish a pattern for monorepos for applications with multiple modules.
  • End-to-end testing using Microsoft Playwright
  • API calls with autorest-typescript or openapi-typescript-codegen
  • react-query based caching, using react-router v6.4.x and orval based API code generation (plenty documentation available in the README)
  • Storybook to prototype components

Standard Libraries and UI components:

As a general rule of thumb, we want to avoid tooling in anything we don't need to. Unless absolutely necessary we use yarn to run any scripts e.g generating OpenAPI clients.

Developing the client

The React app is configured to run with a signed SSL certificate and proxy the FastAPI application running in the development container.

Once setup you can run the development servers via:

yarn start

React uses the HTTPS environment variable to run run in secure mode HTTPS=true yarn start, we make a more permanent change in package.json to save us having to do this manually.

SSL during Development

The intent here is run SSL (which is as close to development as possible) but without needing to introduce yet another tool (e.g a reverse proxy).

mkcert allows us to provision a SSL certificate for our development environment.

Install mkcert via brew:

$ brew install mkcert

Install nss (this is only needed if you use Firefox)

$ brew install nss

Setup mkcert on your machine (this register it as a certificate authority)

$ mkcert -install

Next we modify the start script to use SSL, this saves us having to set the HTTPS environment variable manually:

"scripts": {
    "start": "HTTPS=true react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

Create .cert directory if it doesn't exist

mkdir -p .cert

Generate the certificate (ran from the root of this project)

mkcert -key-file ./.cert/key.pem -cert-file ./.cert/cert.pem "localhost"

Lastly update package.json so the start script can use the generated certificate:

  "scripts": {
    "start": "HTTPS=true SSL_CRT_FILE=./.cert/cert.pem SSL_KEY_FILE=./.cert/key.pem react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

tsconfig.json modifications

Amongst other things we add the src folder to the include section so that the TypeScript compiler can find our source files.

{
  "compilerOptions": {
  },
  "include": [
    "src"
  ]
}

the imports read from the top of the src folder e.g:

import { UserTypeRequest } from 'api/models';

Using React to proxy the API

Our intention is to keep things simple and not introduce tools unless it's absolutely necessary. CRA provides a guide to proxying APIs calls. There's a simple setup and then a more complex one that uses http-proxy-middleware.

We need to use the later for so we can configure stripping the prefix from the API calls. This is necessary because of the way our FastAPI application is configured in the docker container. You can read about this in the server labs.

In principle what we are doing is stripping the /api prefix from the calls i.e /api/ext/echo is proxied to /ext/echo on http://localhost:8000.

http-proxy-middleware provides a pathRewrite option to achieve this, this is detailed in their documentation, the following is an example that works for us:

const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:8000/',
      changeOrigin: true,
      pathRewrite: {'^/api' : ''} // Removes the prefix so the container responds properly
    })
  );
};

Routing, Data Loading and State Management

Most React developers start off using a Router library and in the more recent times use a Context to store the state of the user interface and in many instances the asynchronous data (i.e from a remote source, typically an API). As we trying and refactor the application code to separate user interface elements from data state, we often end up with issues like Deduping multiple requests (where the same request end point is called multiple times, mostly due to the state being re-initialised on paints).

Until recently these have been three separate topics. While React does not have an opinion on a solution for this, recent developments like React Router introducing data loading in v6.4 has had us revisit all three components in unison.

React Router is about when, data caching libs are about what. - Ryan Florence

As we apply our templates to larger problems, we must now think of the most efficient patterns to handle these complex use cases. To this effect we:

  • Separate application UI state and asynchronous data state
  • Use react-query to handle caching, deduping, reflection of data
  • Use react-router and react-query in harmony
  • Use orval to generate the api client that can be used by react-query

Routing and data loading

React Router 6.4 changes the way it's configured and introduces data loading, the two major concepts to take note of are:

  • Loaders, which are async functions fired before the route is rendered
  • Actions, which are async function fired from a <Form>

This is in attempt to provide a HTML + HTTP like with react-router doing the async work.

A loader or an action can throw an exception which signals the Router to load the errorElement, which is meant to be a fallback components for error states.

The following is a code extract to how react-router is configured with react-query, it will become clear how we are using the two libraries together.

First of all note that the router is created using the createBrowserRouter method which uses the DOM History API. Children are passed as an array to the routes, and it's behaviour is that of when we nested <Route> and used an <Outlet> in the parent container.

import {
  RouterProvider,
  createBrowserRouter,
} from "react-router-dom";

import {
    QueryClient,
    QueryClientProvider,
} from 'react-query'

import { ReactQueryDevtools } from 'react-query/devtools'

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    errorElement: <ErrorPage />,
  },
  {
    path: "/auth",
    element: <Authentication />,
    children: [
      {
        path: "login",
        element: <Login />,
      },
      {
        path: "otp",
        element: <OTP/>
      }
    ]
  }
]);

const queryClient = new QueryClient();

root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
      <ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
    </QueryClientProvider>
  </React.StrictMode>
);

The QueryClientProvider wraps the entire application. <ReactQueryDevtools> injects the developer tool into the application for react-query and it stripped away in production builds.

The queryClient is used to invalidate the cache, we will talk about in the next section.

errorElement is rendered on errors like when a path isn't found, this can be provided per path. The one on the Root is used to render an error page if a path isn't found.

Further reading:

API Clients

Before we get into how we use configure and use react-query and react-router loaders together, lets meet Orval, the API client generator. Orval does way more than just generate a type checked API client, but for the purposes of this guide we will focus on the API client portion.

Add orval to be globally available:

yarn global add orval

Add/modify the orval.config.js located on the root folder of the project:

module.exports = {
  labs: {
    output: {
      mode: 'tags-split',
      target: 'src/api/labs.ts',
      schemas: 'src/api/models',
      client: 'react-query',
      mock: true,
    },
    input: {
      target: './openapi.json',
    },
  },
};

By default we place the api and models in src/api these are split into files and grouped by the OpenAPI tags assigned on the server side. These are wrapped as two yarn scripts:

  • fetch-openapi - fetches the OpenAPI spec from the server and places it on the root
  • codegen - fetches the OpenAPI sepc and generates the API client

The generated code depends on faker-js, axiosandmsw`, install them as follows:

yarn add axios
yarn add @faker-js/faker --dev
yarn add msw --dev

If you have cloned the project from our template these should already be part of the dependencies.

The component should use the hook generated by the client and the loader should use the generated helper method

export const loader =
  (queryClient) =>
  async ({ params }) => {
    const query = contactDetailQuery(params.contactId);
    return (
      queryClient.getQueryData(query) ?? (await queryClient.fetchQuery(query))
    );
  };

State Management

While most traditional state management libraries are great for working with client state, they are not so great at working with async or server state. This is because server state is totally different. For starters, server state:

  • Is persisted remotely in a location you do not control or own
  • Requires asynchronous APIs for fetching and updating
  • Implies shared ownership and can be changed by other people without your knowledge
  • Can potentially become "out of date" in your applications if you're not careful

Once you grasp the nature of server state in your application, even more challenges will arise as you go, for example:

  • Caching... (possibly the hardest thing to do in programming)
  • Deduping multiple requests for the same data into a single request
  • Updating "out of date" data in the background
  • Knowing when data is "out of date"
  • Reflecting updates to data as quickly as possible
  • Performance optimizations like pagination and lazy loading data
  • Managing memory and garbage collection of server state
  • Memoizing query results with structural sharing

Reference: Motivation from the react-query documentation.

We've all comes across these issues in React, or you eventually will. Tanner Linsley's presentation React Query: It’s Time to Break up with your "Global State"! gives an overview of these issues and how react-query goes a long way to address them.

react-query handles the following use cases rather well:

  • Background fetching
  • Parallel queries
  • Window focus refetching
  • Query retries
  • Paginated queries

Developer tools

 import { ReactQueryDevtools } from 'react-query/devtools'

Further reading:

Putting it all together

To make relative links just go

<Link to={`users/${user.id}`}>Edit</Link>

Form is a wrapper provided by react-router

Questions:

  • Example uses useLoader in the editor screen to load data, but I am using useGetUserById which is an orval thing, is this correct
  • Examples use {user?.firstName} where as I am having to do {user?.data.firstName}, where am I going wrong?

Thoughts on naming, using operation IDs

You'll notice the names of the autogenerated methods to be meAuthMeGet, which is not ideal for readability. This is a result of of what the Open API spec calls operationId which is used to generate the method name. The operationId is a unique identifier for the operation and is used to generate the method name. The operationId is not required in the Open API spec, but it is recommended to provide one.

Our Python server lab outlines how to configure the operationId in FastAPI, which greatly improves the readability of the generated clients. The evolved templates is able to name things based on the function names, so long as your api function names are pretty, so will your documentation 😀.

Wisdom: Write code to be read

Conventions

The following are opinionated conventions for the Anomaly templates. They have been informed by UNIX based systems modern web development techniques:

Our react components are always named in PascalCase:

export const MyComponent = () => {
  return <div>MyComponent</div>;
};

The file containing the component MyComponent must be same as the component but in kebab-case i.e. my-component.tsx.

When instantiating a component the instance must be assigned to a variable name in camelCase

macOS by default uses a type insensitive file system, using CamelCase for file names can cause issues on other platforms. This will be masked if you build on your local desktop but will come to light if you are using a CI/CD pipeline.

Internationalization

Even thought your application may never be used across regions (highly unlikely if you are building a global product), it's still a good idea to internationalize your application. This is because it's a good practice to get into and it's a good way to learn about the tools and techniques. It is also easier to internationalize your application from the start than to do it later.

Things to consider are:

  • The interfaces lay themselves towards right-to-left languages
  • Ability to provide multiple locales (even English varies between countries like USA and the UK)

There are several providers like Locaize that help with the translation services.

Setting up react-i18n

We use the react-i18next library for internationalization. The following steps are taken to set up the library:

yarn add i18next react-i18next --save

additionally you can add i18next-browser-languagedetector for automatic language detection (this is up to your use case, if you want the user to choose their language or inherit from their browser settings).

yarn add i18next-browser-languagedetector

Namespaces are react-i18n way of allowing projects to have translations split into multiple files.

Our setup is loosely based on the react-typescript example with multiple namespaces.

Conventions for our file and folder structure is as follows:

  • group namespaces into languages named by the language code e.g en or more specifically en-AU
  • each namespace should be a file named by the namespace e.g common.json or dashboard.json that relates to a particular part of your application.
  • Each language must provide every namespace declared by the application
  • The set of master translations should be in the primary language of the application e.g en-AU or en-US and the others follow
  • The i18n/config.ts is the configuration file for the internationalisation library. This is what's imported in index.ts

The @types folder has the type definitions requires for the internationalisation to work with TypeScript.

Trnaslation files are JSON files (placed in i18n/language_code/namespace.json):

{
  "title": {
    "welcome": "Welcome to {{site_name}}"
  }
}

Note that we have nested keys, this is to allow for more complex translations. The {{site_name}} is a placeholder for the value of site_name which is passed in as a parameter to the t function.

Once this is setup you can use the useTranslation hook to access the translations in your components (notice the site_name variable being provided a dynamic value).

import { Helmet } from 'react-helmet';

import {
  useTranslation
} from "react-i18next";

function App() {

  // Internationalisation
  // @ts-ignore
  const { t } = useTranslation();

  return (
    <div className="flex flex-col justify-center w-screen h-screen font-bold text-black app">
      <Helmet>
        <title>
        {t('title.welcome', {
          site_name: "Labs"
        })}
        </title>
        <meta name="description" content="Welcome to Anomaly Labs" />
      </Helmet>
    </div>
  );
}

export default App;

You don not need to do this as the project is already configured for internationalization, these are just notes for reference.

Structure

The following is an evolving conversation. While there's no official pattern for folder structures, what we have obtained from reading various articles from experienced developers is that project structures are an evolving idea, in general we follow these principles:

  • src/components, contain components that are truly reused around the project, if there's a particular component tied to a view then it makes more sense to keep it within the view folder.
  • src/views, contains views for the application. These should be grouped by function e.g authentication, dashboard.

Docker container for production

For many instances we serve that built web client via an S3 compatible object store. Most infrastructure providers allow serving files from an S3 compatible object store (see the following section for details), however in simpler use cases you use a Docker container to serve the static files.

Refer to the Dockerfile on this repository to see how we setup a two phase build that:

  • Sets up an environment with the package.json and yarn.lock file using the NodeJS image
  • Builds the react application
  • Builds an nginx image and copies the built React app to the image

Note: we're literally using the default nginx configuration to serve the files

To test the setup, first build the image using:

docker build -t lab-web-client -f Dockerfile .

you can test the image by using the built image:

docker run -p 8080:80 lab-web-client

which will expose port 80 from the image to port 8080 on your local machine. You can then visit http://localhost:8080 to see the application. When building for deployment on x86/64 architecture you must specify the platform --platform=linux/amd64 via the flag.

S3 Wisdom

These remarks are made around Linode's implementation of an object store which is S3 compatible. These are hopefully translatable across any S3 compatible service.

Object store bucket names are unique in a region, so it's best to come up with a pattern (e.g FQDN) so we never conflict with users around world.

This should ideally be done as part of a CI/CD process, but the commands are nice to have for reference and are handy for development builds. We encourage that you keep the bucket related secrets in an .env file, this translates directly into storing this a CI/CD environment.

Note: the keys are secrets and should never be versioned, it's also advisable to cycle these keys from time to time.

ACCESS_KEY=LEGG.....
SECRET_KEY=qUuXn.....
BUCKET_FQDN=lab-web-client.ap-south-1.linodeobjects.com
BUCKET_NAME=lab-web-client
BUCKET_REGION_FQDN=ap-south-1.linodeobjects.com

You will need to prepare the bucket to serve static files, the s3cmd can help you with this:

export $(cat .env) && s3cmd 
  --access_key=$ACCESS_KEY 
  --secret_key=$SECRET_KEY 
  --host=$BUCKET_REGION_FQDN 
  --host-bucket=$BUCKET_FQDN 
  ws-create 
  --ws-index=index.html 
  --ws-error=index.html 
  s3://$BUCKET_NAME/

This is a one time process, following this step the bucket should be ready to serve files.

Once you've built client you can use a tool like s3cmd to synchronise the files to the S3 bucket.

export $(cat .env) && s3cmd 
  --access_key=$ACCESS_KEY 
  --secret_key=$SECRET_KEY 
  --host=$BUCKET_REGION_FQDN 
  --host-bucket=$BUCKET_FQDN 
  sync 
  --no-mime-magic 
  --delete-removed
  --delete-after
  --acl-public 
  build/* 
  s3://$BUCKET_NAME/

Buckets are also required to be empty before you can drop them from the provider:

export $(cat .env) && s3cmd 
  --access_key=$ACCESS_KEY 
  --secret_key=$SECRET_KEY 
  --host=$BUCKET_REGION_FQDN 
  --host-bucket=$BUCKET_FQDN 
  del 
  --recusrive 
  --force
  s3://$BUCKET_NAME/

If you are using an alternate Object Store like one provided by Linode, then you might need to provide additional context by changing the suffix values of the buckets. For example Linode buckets are suffixed with linodeobjects.com and the region is suffixed with the region name.

Create a file called .s3cfg in your home directory with the following values:

website_endpoint=http://%(bucket)s.website-ap-south-1.linodeobjects.com

this will result in the cli outputting the correct URL for the bucket.

Bucket s3://lab-web-client/: Website configuration
Website endpoint: http://lab-web-client.website-ap-south-1.linodeobjects.com
Index document:   index.html
Error document:   index.html

Note: while the bucket's FQDN is lab-web-client.ap-south-1.linodeobjects.com to serve the web site you must use the generated address of lab-web-client.website-ap-south-1.linodeobjects.com. The FQDN is used to serve the context of the bucket not a site.

End-to-end and API testing

We use Microsoft Playwright for end-to-end and API testing, including the use of Github actions to automate testing on pull requests. While we don't intend to replicate all the documentation here, the intent is to maintain information about the portions of the tool that we use and how we think it's best configured.

To configure Playwright for the project use:

yarn create playwright

Follow the prompts to complete installation, we generally use the default values

To run the headless tests use:

npx playwright test

and to view reports (this will end up running a web server with with the reports displayed as a site):

npx playwright show-report

Recording new tests

npx playwright codegen playwright.dev

Developer Tools

Anomaly primarily uses Visual Studio Code as the standard code editor. Here are some useful plugins that has proven useful for our development team:

  • GitHub co-pilot - AI assisted code completion
  • GitLess - supercharged Git workflows
  • Headwind - An opinionated class sorter for Tailwind CSS, ensures that every developers classes are in the same order

References

Articles:

License

Contents of this repository are licensed under the Apache 2.0 license.