First steps

November 14, 2023

Here we will present the first steps to get your Jahia UI extension module up and running. This section is focused on getting things done, if you are interested in learning more on how things work, check out our Jahia UI Under the hood and Understanding UI extensions documentation.

Create the Maven project

Steps:

  1. Create a new directory for your project; we will refer to it as acme

  2. Change into the acme directory

  3. Add a pom.xml with the following content

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <artifactId>jahia-modules</artifactId>
        <groupId>org.jahia.modules</groupId>
        <version>8.1.0.0</version>
    </parent>
    <artifactId>acme</artifactId>
    <groupId>org.acme</groupId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>bundle</packaging>

    <repositories>
        <repository>
            <id>jahia-public</id>
            <name>Jahia Public Repository</name>
            <url>https://devtools.jahia.com/nexus/content/groups/public</url>
            <releases>
                <enabled>true</enabled>
                <updatePolicy>never</updatePolicy>
            </releases>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </repository>
    </repositories>

</project>

Add the frontend-maven-plugin

Steps:

  1. Just above the close project tag, add the following content to the pom.xml file

     <build>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <artifactId>maven-clean-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <filesets>
                        <fileset>
                            <directory>src/main/resources/javascript/apps</directory>
                            <includes>
                                <include>*</include>
                            </includes>
                        </fileset>
                    </filesets>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.github.eirslett</groupId>
                <artifactId>frontend-maven-plugin</artifactId>
                <version>1.8.0</version>
                <executions>
                    <execution>
                        <id>npm install node and yarn</id>
                        <phase>generate-resources</phase>
                        <goals>
                            <goal>install-node-and-yarn</goal>
                        </goals>
                        <configuration>
                            <nodeVersion>v14.16.0</nodeVersion>
                            <yarnVersion>v1.22.11</yarnVersion>
                        </configuration>
                    </execution>
                    <execution>
                        <id>yarn install</id>
                        <phase>generate-resources</phase>
                        <goals>
                            <goal>yarn</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>yarn post-install</id>
                        <phase>generate-resources</phase>
                        <goals>
                            <goal>yarn</goal>
                        </goals>
                        <configuration>
                            <arguments>${yarn.arguments}</arguments>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
    <profiles>
        <profile>
            <id>dev</id>
            <properties>
                <yarn.arguments>build</yarn.arguments>
            </properties>
        </profile>
    </profiles>

The above configuration does a few things:

  • Sets up the clean maven plugin to clean the generated Javascript files

  • Sets up the frontend-maven-plugin to use NodeJS and Yarn to build the project. You might want to adjust the version numbers of those frameworks to use the latest ones.

  • Adds a dev profile to be able to build the Yarn project in development mode instead of production mode (which is often the default mode)

Add a package.json file

We must now add a Yarn project file called package.json that should be initialized like this:

{
  "name": "acme",
  "version": "0.0.1",
  "husky": {
    "hooks": {
      "pre-push": "yarn lint:fix"
    }
  },
  "scripts": {
    "test": "env-cmd --no-override jest",
    "testcli": "jest",
    "build": "yarn lint:fix && yarn webpack",
    "build:nolint": "yarn webpack",
    "dev": "yarn webpack --watch",
    "watch": "yarn webpack --watch",
    "webpack": "node --max_old_space_size=2048 ./node_modules/webpack/bin/webpack.js",
    "build:analyze": "yarn build --analyze",
    "build:production": "yarn build --mode=production",
    "build:production-analyze": "yarn build --mode=production --analyze",
    "clean": "rimraf *.log src/main/resources/javascript/apps",
    "clean:all": "yarn clean && rimraf node_modules node",
    "lint": "eslint --ext js,jsx,json . && stylelint './src/**/*.scss'",
    "lint:fix": "eslint --ext js,jsx,json --fix . && stylelint --fix './src/**/*.scss'"
  },
  "main": "index.js",
  "license": "MIT",
  "dx-extends": {
    "@jahia/jahia-ui-root": "0.0.1"
  },
  "jahia": {
    "remotes": {
      "jahia": "javascript/apps/remoteEntry.js"
    }
  },
  "dependencies": {
    "@apollo/react-hooks": "^3.1.3",
    "@jahia/data-helper": "^1.0.0",
    "@jahia/moonstone": "^1.0.0",
    "@jahia/ui-extender": "^1.0.0",
    "graphql-tag": "^2.10.3",
    "prop-types": "^15.7.2",
    "react": "^16.13.0",
    "react-apollo": "^3.1.3",
    "react-dom": "^16.13.0",
    "react-i18next": "^11.2.2",
    "react-redux": "^7.2.0",
    "react-router": "^5.1.2",
    "react-router-dom": "^5.1.2",
    "redux": "^4.0.0",
    "redux-actions": "^2.6.5"
  },
  "resolutions": {
    "*/**/minimist": "1.2.6"
  },
  "devDependencies": {
    "@babel/cli": "^7.6.2",
    "@babel/core": "^7.6.2",
    "@babel/plugin-proposal-class-properties": "^7.5.0",
    "@babel/plugin-transform-classes": "^7.4.4",
    "@babel/plugin-transform-runtime": "^7.5.0",
    "@babel/preset-env": "^7.6.2",
    "@babel/preset-react": "^7.0.0",
    "@babel/preset-typescript": "^7.3.3",
    "@babel/runtime": "^7.5.4",
    "@jahia/eslint-config": "^1.1.0",
    "@jahia/stylelint-config": "^0.0.3",
    "@jahia/test-framework": "^1.1.5",
    "axios": "^0.21.4",
    "babel-jest": "^24.9.0",
    "babel-loader": "^8.0.6",
    "clean-webpack-plugin": "^3.0.0",
    "copy-webpack-plugin": "^9.0.1",
    "css-loader": "^3.2.0",
    "eslint": "^6.7.2",
    "eslint-loader": "3.0.3",
    "eslint-plugin-jest": "^23.8.0",
    "eslint-plugin-json": "^2.1.0",
    "eslint-plugin-react": "^7.18.3",
    "eslint-plugin-react-hooks": "^2.5.0",
    "file-loader": "^6.2.0",
    "husky": "^3.0.9",
    "jest": "^24.9.0",
    "jest-image-snapshot": "^2.11.0",
    "jest-puppeteer": "^4.3.0",
    "jest-teamcity-reporter": "github:mhodgson/jest-teamcity-reporter",
    "node-sass": "^6.0.1",
    "path": "^0.12.7",
    "puppeteer": "^2.0.0",
    "puppeteer-edge": "^0.12.4",
    "puppeteer-firefox": "^0.5.0",
    "rimraf": "^3.0.0",
    "sass-loader": "^12.1.0",
    "style-loader": "^1.0.0",
    "stylelint": "^13.2.0",
    "webpack": "^5.52.0",
    "webpack-bundle-analyzer": "^3.5.2",
    "webpack-cli": "^4.8.0"
  }
}

Here it is not very important to understand all the contents of this file, but the most crucial part is to understand that we have all the proper setups to generate a federated module.

Add a webpack.config.js file

Next is the WebPack configuration file, which is responsible for packing all the Javascript project resources together and transforming the React code into plain Javascript and that will set up the module federation to be able to extend the UI:

const path = require('path');
const webpack = require('webpack');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const shared = require("./webpack.shared")

module.exports = (env, argv) => {
    let config = {
        entry: {
            main: [path.resolve(__dirname, 'src/javascript/index.js')]
        },
        output: {
            path: path.resolve(__dirname, 'src/main/resources/javascript/apps/'),
            filename: 'acme.bundle.js',
            chunkFilename: '[name].acme.[chunkhash:6].js'
        },
        resolve: {
            mainFields: ['module', 'main'],
            extensions: ['.mjs', '.js', '.jsx', 'json']
        },
        module: {
            rules: [
                {
                    test: /\.m?js$/,
                    type: 'javascript/auto'
                },
                {
                    test: /\.jsx?$/,
                    include: [path.join(__dirname, 'src')],
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                               ['@babel/preset-env', {modules: false, targets: {safari: '7', ie: '10'}}],
                                '@babel/preset-react'
                            ],
                            plugins: [
                                '@babel/plugin-syntax-dynamic-import'
                            ]
                        }
                    }
                },
                {
                    test: /\.css$/,
                    sideEffects: true,
                    use: ['style-loader', 'css-loader']
                },
                {
                    test: /\.scss$/i,
                    sideEffects: true,
                    use: [
                        'style-loader',
                        {
                            loader:'css-loader',
                            options: {
                                modules: true
                            }
                        },
                        'sass-loader'
                    ]
                },
                {
                    test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
                    use: [{
                        loader: 'file-loader',
                        options: {
                            name: '[name].[ext]',
                            outputPath: 'fonts/'
                        }
                    }]
                }
            ]
        },
        plugins: [
            new ModuleFederationPlugin({
                name: "acme",
                library: { type: "assign", name: "appShell.remotes.acme" },
                filename: "remoteEntry.js",
                exposes: {
                    './init': './src/javascript/init',
                },
                remotes: {
                    '@jahia/app-shell': 'appShellRemote',
                },
                shared
            }),
            new CleanWebpackPlugin({verbose: false}),
            new CopyWebpackPlugin({patterns: [{from: './package.json', to: ''}]}),
        ],
        mode: 'development'
    };

    config.devtool = (argv.mode === 'production') ? 'source-map' : 'eval-source-map';

    if (argv.analyze) {
        config.devtool = 'source-map';
        config.plugins.push(new BundleAnalyzerPlugin());
    }

    return config;
};

You will also need a webpack.shared.js file where the shared libraries are defined:

const deps = require('./package.json').dependencies;
const sharedDeps = [
    '@babel/polyfill',
    'react',
    'react-dom',
    'react-router',
    'react-router-dom',
    'react-i18next',
    'i18next',
    'i18next-xhr-backend',
    'graphql-tag',
    'react-apollo',
    'react-redux',
    'redux',
    'rxjs',
    'whatwg-fetch',
    'dayjs',

    // JAHIA PACKAGES
    '@jahia/ui-extender',
    '@jahia/moonstone',
    '@jahia/moonstone-alpha',
    '@jahia/data-helper',

    // Apollo
    '@apollo/react-common',
    '@apollo/react-components',
    '@apollo/react-hooks',

    // DEPRECATED JAHIA PACKAGES
    '@jahia/design-system-kit',
    '@jahia/react-material',
    '@jahia/icons'
];

const singletonDeps = [
    'react',
    'react-dom',
    'react-router',
    'react-router-dom',
    'react-i18next',
    'i18next',
    'react-apollo',
    'react-redux',
    'redux',
    '@jahia/moonstone',
    '@jahia/ui-extender',
    '@apollo/react-common',
    '@apollo/react-components',
    '@apollo/react-hooks'
];
const notImported = ['@jahia/moonstone'];
module.exports = {
    ...sharedDeps.reduce((acc, item) => ({
        ...acc,
        [item]: {
            requiredVersion: deps[item]
        }
    }), {}),
    ...singletonDeps.reduce((acc, item) => ({
        ...acc,
        [item]: {
            singleton: true,
            requiredVersion: deps[item]
        }
    }), {}),
    ...notImported.reduce((acc, item) => ({
        ...acc,
        [item]: {
            import: false,
            requiredVersion: deps[item]
        }
    }), {})
};

This configuration prevents the libraries from bundling in the package generated by Webpack but instead expects them to be available in the shared HTML page context.

You will then need an init.js file that you should create in the src/javascript directory with the following content:

import {registry} from '@jahia/ui-extender';
import register from './Acme/register';

export default function () {
    registry.add('callback', 'acme', {
        targets: ['jahiaApp-init:5'],
        callback: register
    });
}

And in the src/javascript/Acme directory, create a register.js file

import {registry} from '@jahia/ui-extender';
import React from 'react';

export default () => {
    registry.add('primary-nav-item', 'acmeGroupItem', {
        targets: ['nav-root-tasks:1'],
        render: () => <AcmeGroup/>
    });
};
Todo: finish the example with a Hello World component