Creating a React Client Extension

In a recent blog, Intro to Objects, I created a CourseRegistration Object and then used regular Liferay Fragments for the Presentation. Now I'm going to build a React custom element to do that work.

Introduction

In a recent blog, Intro to Liferay Objects, I created a new application for the Masterclass site for submitting and approving course registrations entirely using OOTB features of Liferay, specifically Objects for the data store, and Fragments for the presentation (form fragments and collection display fragments and Masterclass fragments).

This approach would allow for the creation of custom functionality in the site without having to write a line of code.

But what if we needed some additional functionality that could not be handled with Liferay fragments? What if we wanted to build more of a UI application to handle the presentation requirements?

What if we just wanted to build the UI in React?

Well, that's absolutely what we're going to do here. Not because the application from the last blog necessarily requires it, but more because the application is a decent starting point.

Application Review

To review the application, basically we have created a Course Registration object that holds the course, notes, attendee and registration status.

Students are allowed to submit course registrations, and a Course Administrator can approve/deny the registration. We had two different table UIs, one for the user to see the status of their own records, and one for administrators where they could approve or deny the registrations.

Although we built this on one page using a Form container and another page that listed registrations, we really didn't have any requirements to separate them that way, it was just an easy way to handle it.

But now let's add the requirement on presentation, say we want something like:

The new UI has a live filter to filter the results, and an integrated Add button to add a new course registration. There's a table for the students that shows their courses and status, and the table for the Course Administrator has more details, and the available actions take the current status into account (i.e. you can't approve a course that has already been approved).

Given these new requirements, we have decided to build the UI in React, so let's get started...

Did we need to build this in React? Actually, no. We could have opted to use the Course Registration Widget (generated for us when we create the Object and when the Show Widget in Page Builder option is set), and there's also other features such as Datasets which can do a similar presentation, but for purposes of this blog let's assume that whatever the requirements are, after a thorough review of OOTB features we have determined there is no [easy] way to handle this UI without using a JS framework.

Previewing The Application

Since I already have the React application built, I can showcase how it will look...

First, we have the view from the student's perspective:

 

Note how they only see their own course registrations.

The course administrator has an advanced view:

 

The available action buttons will change depending upon the status of the individual line item. The action buttons, when clicked, will change the status right away and reflect the change in the UI.

There's a form for creating a new course registration:

 

And, when successfully created, will even display a success toast:

 

The course administrator will see the new registration in the list and can approve or deny the registration:

 

And there's even a detail view:

 

When viewed as the student, the approve/deny buttons will not be available.

Now that we've seen how it will look, let's build it...

Starting The Project

Okay, so we're going to build a React custom element using Client Extensions (CX). But why?

First, the choice of React is quite arbitrary. I could have easily decided to go with Angular or Vue.js or plain old Javascript or any other framework I wanted, including jQuery or others. My choice of React is just because I feel that I know that one better than the rest, but that was certainly not the only choice.

Building a custom element CX may seem like an odd choice, but it really isn't. The Fragment Toolkit has been deprecated, so I can't depend upon it for creating a custom fragment. The custom Fragment Editor UI might work, but that's a lot to try and accomplish using just the Liferay UI to develop a React fragment.

Additionally a CX is the only way I can build for SaaS, PaaS and Self Hosted, so that's a big reason to choose using a CX.

I don't have a Liferay Workspace for my Masterclass site, so I'm going to have to start a new workspace for building my CX.

To do this, I'm actually just going to follow the steps from another recent blog, From React App to React Client Extension in 5 Easy Steps. I don't have an existing app, but I did cover in that blog how I would get the liferay-samples workspace, clear out the existing CX, then create a new React app in the client-extensions folder using the command:

$ yarn create vite course-registrations --template react

And that's what I did here, I copied the liferay-sample-workspace workspace from the https://github.com/liferay/liferay-portal repository and named it simply masterclass, I edited the gradle.properties file to use GA 112 for the target, I purged all of the existing client extensions from the client-extensions folder, and then I used Vite to create my new React application named course-registrations.

Seems like a lot of work to start a project, but it is necessary. Client Extensions are still under active development with new CX being introduced, but only this sample workspace is updated with workspace plugin versions, etc., updated to reflect the changes. Additionally Liferay's CI/CD uses this sample workspace to build and test the CX, so if the code is working in the sample workspace, it should work in our clone.

So these are the steps that I took to create my new CX workspace:

  1. Check out or download the zip from https://github.com/liferay/liferay-portal, being sure to use the master branch.
  2. Copy the workspaces/liferay-sample-workspace to wherever your workspace needs to be, give it an appropriate name (I just called mine masterclass).
  3. Purge everything from masterclass/client-extensions that does not start with liferay-sample-custom-element (we'll whittle those down in just a bit) as those CX won't really be necessary for this task, although you could choose to keep them for a reference if you'd like.
  4. Depending upon which JS framework you're going to use, find the right liferay-sample-custom-element example that matches. Remove the rest, make a copy of the module and give it an appropriate name.
  5. Use Vite to create a brand new project in the client-extensions folder where we'll build the custom element.

liferay-sample-custom-element-1 is a vanilla JS CX. liferay-sample-custom-element-3 is an Angular-based CX. liferay-sample-custom-element-2 is a create-react-app-based CX. liferay-sample-custom-element-4 and 5 use the React that Liferay shares (leading to smaller CX size), but 5 also includes Clay for a consistent Liferay look and feel.

Although I'm not using any of the CX as a starting point (since I created my new component using Vite), I kept around liferay-sample-custom-element-5 as a sample and deleted the others.

It will be tempting, I'm sure, to check in the gradle.properties file and realize that it's not using the quarterly version that you are. I would encourage you not to change it. In my copy of the workspace it is using 2023.Q4.5 and I want to go with CE GA112. It is absolutely tempting to change it, but here's what I've learned...

Remember the CI/CD checks I mentioned earlier? Well they perform these tests using these settings. If you change the settings and something fails on your CX, you're really not going to know if it was something in your CX or whether it was due to using a different version. Stick with the settings Liferay uses in the CI/CD for your development, and once everything tests out, then start changing the version to match your target environment. This way you'll have the CX working in a known environment and, should something fail when targeting your real environment, you'll know that it is a platform issue and not a CX issue.

Now the workspace is ready, so I load it into VSCode so I can start making my changes...

Reviewing The Sample

Before we start building the new application, let's take a quick look at the sample's code.

import ClayBadge from '@clayui/badge';
import React from 'react';
import ReactDOM from 'react-dom';

class CustomElement extends HTMLElement {
  connectedCallback() {
    ReactDOM.render(
      React.createElement(ClayBadge, {
        displayType: 'success',
        label: 'Success!',
      }),
      this
    );
  }

  disconnectedCallback() {
    ReactDOM.unmountComponentAtNode(this);
  }
}

const ELEMENT_NAME = 'liferay-sample-custom-element-5';

if (customElements.get(ELEMENT_NAME)) {
  // eslint-disable-next-line no-console
  console.log(
    `Skipping registration for <${ELEMENT_NAME}> (already registered)`
  );
} else {
  customElements.define(ELEMENT_NAME, CustomElement);
}

First comes the CustomElement class. It's extending HTMLElement, and that's what will make it a custom element when we add to a page. It contains two methods, connectedCallback() for when the element is connected to the DOM and disconnectedCallback() when it is being removed (this is necessary so React will clean itself up properly).

The connectedCallback() method just implements a Clay badge but otherwise does nothing interesting.

The bottom of the file has the custom element registration code. It's important that each custom element that you build has a unique name so it doesn't get confused with another element.

When I got to the this point in the blog and I was reviewing the sample, my first thought was "Hey, what happened to the api() method?" In liferay-sample-custom-element-4, there's an additional method, api(), that demonstrates how to make a headless call. I grabbed that function to include in my custom element, here it is:

const api = async (url, options = {}) => {
  return fetch(window.location.origin + '/' + url, {
    headers: {
      'Content-Type': 'application/json',
      'x-csrf-token': Liferay.authToken,
    },
    ...options,
  });
};

The great part about this method is it uses the current user authentication, so we won't have to worry about OAuth2 when we invoke headless APIs from the custom element code.

Building The Application

Vite has started the new application, so it's time to get it ready...

The index.html file that Vite starts with needs to be modified so that it renders the custom element. My file has:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
  </head>
  <body>
    <course-registrations></course-registrations>>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Next, Vite also created a main.jsx file that defines the primary element, and that needs to be updated too. Mine has:

import React, { StrictMode } from 'react'
import { ClayIconSpriteContext } from '@clayui/icon';
import App from './App.jsx'
import './index.css'
import getIconSpriteMap from './util.js'
import stringToBoolean from './stringToBoolean.js';

import { render, unmountComponentAtNode } from 'react-dom';

/**
 * WebComponent: The main web component that renders the App component.
 */
class WebComponent extends HTMLElement {

  /**
   * connectedCallback: Renders the App component into the web component.
   */
  connectedCallback() {
    render(<StrictMode>
        <ClayIconSpriteContext.Provider value={getIconSpriteMap()}>
          <App admin={ stringToBoolean(this.getAttribute('admin')) } />
        </ClayIconSpriteContext.Provider>
     </StrictMode>, this);
  }

  /**
   * disconnectedCallback: Unmounts the App component from the web component. 
   * Ensures that anything the component was doing is properly cleaned up and 
   * released.
   */
  disconnectedCallback() {
    unmountComponentAtNode(this);
  }
}

const ELEMENT_NAME = 'course-registrations';

if (customElements.get(ELEMENT_NAME)) {
  // eslint-disable-next-line no-console
  console.log(`Skipping registration for <${ELEMENT_NAME}> (already registered)`);
} else {
  customElements.define(ELEMENT_NAME, WebComponent);
}

Here I'm still doing the connect and disconnect handling, but I'm using the React 18 way of handling this.

Finally, Vite creates the App.jsx file that defines the application. This of course has to change, and mine has:

/**
 * App.jsx - This file defines the main component of the application.
 */

import React, { useState, useEffect } from 'react'
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import TableComponent from './TableComponent.jsx';
import DetailComponent from './DetailComponent.jsx';
import FormComponent from './FormComponent.jsx';
import AdminTableComponent from './AdminTableComponent.jsx';
import { RegistrationStatusERC, UpcomingCoursesERC } from './constants.js';
import fetchMap from './fetchMap.js';
import ClayAlert from '@clayui/alert';

import './App.css'

/**
 * App: This is the main  component for the course registrations application.
 * @param {*} admin Flag to indicate to display the admin table or not.
 * @returns 
 */
function App({ admin = false}) {
  const [registrationStatuses, setRegistrationStatuses] = useState({});
  const [upcomingCourses, setUpcomingCourses] = useState([{}]);
  const [selectedRegistration, setSelectedRegistration] = useState(null);
  const [toastItems, setToastItems] = useState([]);

  /**
   * fetchRegistrationStatuses: Fetches the map of registration statuses.
   */
  const fetchRegistrationStatuses = async () => {
    try {
      const map = await fetchMap('o/headless-admin-list-type/v1.0/' + 
        'list-type-definitions/by-external-reference-code/'
        + RegistrationStatusERC);
      
      setRegistrationStatuses(map);
    } catch (error) {
      console.log(error);
    }
  };

  /**
   * fetchUpcomingCourses: Fetches a map of the upcoming courses
   */
  const fetchUpcomingCourses = async () => {
    try {
      const map = await fetchMap('o/headless-admin-list-type/v1.0/' + 
        'list-type-definitions/by-external-reference-code/'
        + UpcomingCoursesERC);
    
      setUpcomingCourses(map);
    } catch (error) {
      console.log(error);
    }
  };

  useEffect(() => {
    fetchRegistrationStatuses();
    fetchUpcomingCourses();
  }, []);

  /**
   * handleRowSelected: When a row is clicked in the table of registrations, this 
   * method handles it.
   * @param registration
   */
  const handleRowSelected = (registration) => {
    setSelectedRegistration(registration);
  };

  /**
   * clearSelectedRow: Handled when coming back from a detail view or cancel from a 
   * form view.
   */
  const clearSelectedRow = () => {
    setSelectedRegistration(null);
  };

  /**
   * renderToast: Renders a toast message.
   * @param {*} title The title of the toast.
   * @param {*} text The text of the toast.
   * @param {*} displayType The display type of the toast.
   */
  const renderToast = (title, text, displayType) => {
    setToastItems([...toastItems, { title: title, text: text, 
      displayType: displayType }]);
  };

  return (
  <>
    <MemoryRouter initialEntries={['/']}>
      <Routes>
      <Route path="/" element={ admin ? 
        <AdminTableComponent onRowSelected={handleRowSelected} 
          onClearSelection={clearSelectedRow} /> 
        : <TableComponent onRowSelected={handleRowSelected} 
          onClearSelection={clearSelectedRow} />} />
      <Route path="/detail" element={ <DetailComponent 
          externalReferenceCode={selectedRegistration ? 
            selectedRegistration.externalReferenceCode : null} 
          onClearSelection={clearSelectedRow} admin={admin} />} />
      <Route path="/add" element={ <FormComponent courses={upcomingCourses} 
          statuses={registrationStatuses} registration={null} 
          onClearSelection={clearSelectedRow} renderToast={renderToast} />} />
      </Routes>
    </MemoryRouter>
    <ClayAlert.ToastContainer>
      {toastItems.map(value => (
        <ClayAlert autoClose={5000} key={value}
          onClose={() => {
            setToastItems(prevItems =>
              prevItems.filter(item => item !== value)
            );
          }}
        displayType={value.displayType} title={value.title}
        >{value.text}</ClayAlert>
      ))}
    </ClayAlert.ToastContainer>
  </>
  )
};

export default App;

Note here that I am using React Router, specifically the <MemoryRouter />, to handle switching views. Using <MemoryRouter /> is fine because it doesn't impact the address bar at all.

Remember that a React app under Liferay does not own the address bar and should not be using it for state management, etc.

As you can tell, I will have some custom elements that the app will be using, and those will need to be defined.

Building The Custom Elements

First, we know that there are some graphical elements that we need to render the presentation. So, from the wireframe, there's some Clay elements that we'll be using:

  • ClayTable to render the table for each view.
  • ClayManagementToolbar for the bar above the table.
  • ClayButton to handle the action buttons.
  • ClayForm for creating a new Course Registration.

So we'll need to import all of these and, of course, there's always the Clay UI website to find each of the visual elements we're going to use along with documentation and examples for how to implement and use them with React. Using yarn to add the various Clay packages gives us the following from package.json:

"@clayui/alert": "^3.111.0",
"@clayui/button": "^3.113.0",
"@clayui/core": "^3.113.0",
"@clayui/css": "^3.113.0",
"@clayui/form": "^3.113.0",
"@clayui/management-toolbar": "^3.111.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3"

Additionally, we kind of have two different views we need to support, one for the regular student and one for the course administrator. Our React code is not going to be able to check whether you have the Course Administrator role, we'll have to pass an indicator whether to render the admin view or not. Fortunately custom elements can have custom attributes, so we'll be adding a custom attribute for the type of view to display, or use the default view when not provided.

You can see how the attribute is grabbed and passed into the application from the line in main.jsx that reads <App admin={ stringToBoolean(this.getAttribute('admin')) } />.

When you drop the custom element on a page, you can then go to the Configuration option and set the property value:

 

The property here becomes an attribute that main.jsx has access to, and my stringToBoolean() method converts the value to a boolean true/false (so I could have admin=yes or admin=true or admin=1 and all of these would work).

I'm not going to show the code for each of the custom components that I've defined, but you can find them in the repo for this blog, https://github.com/dnebing/masterclass-course-registration.

Conclusion

So that's basically it...

Using React + the headless APIs for the Course Registration object, we've built a custom application capable of handling all of the UI requirements we had.

Even though this is custom development, from an application perspective it's pretty light... We don't have to worry about authentication, styling, data persistence, ... We get all of this just by hosting the custom element on Liferay.

In addition, since we built this as a Client Extension (CX), we can deploy this component to SaaS, PaaS, as well as Self Hosted. If we were on Self Hosted today, we could move to SaaS tomorrow and we wouldn't have to change anything about our CX, it would still work.

Also, as a CX, our custom element is external to Liferay. This means that you can update Liferay to the latest quarterly release every quarter, but the CX will not require any changes, any rebuilds, any refactoring, ... It's basically build it once and it works "forever" (well, as much as anything can work forever these days ;-) ).

If you want to check out my repo for this CX, you can find it here: https://github.com/dnebing/masterclass-course-registration