Skip to content

Building the Frontend

Shared libraries

Only the libraries that must be the core's own instance are shared:

Import Why it is shared
react (16.8) Two Reacts break hooks and context
react-dom (16.8) Same
antd (3.x) Theme, styles and popups consistent with the core (loaded on demand)
@clarive/sdk useSdk() and version

Everything else is bundled by the feature (zustand, dayjs, react-query, anything). In particular mobx is not shared: it is not part of the contract. A feature that wants mobx bundles its own version and enables configure({ isolateGlobalState: true }).

Importing anything else without bundling it fails at load time with a clear message in the console: "xxx" is not shared by the SDK (react, react-dom, antd, @clarive/sdk): bundle it in the feature...

vite.config.mjs

import { defineConfig } from 'vite';
import prefixer from 'postcss-prefix-selector';

// must match the feature directory name
const FEATURE_ID = 'hello-react';

export default defineConfig({
    // All CSS ends up under [data-feature="hello-react"] (see Styles below)
    css: { postcss: { plugins: [prefixer({ prefix: `[data-feature="${FEATURE_ID}"]` })] } },

    // React 16.8 has no jsx-runtime: classic React.createElement transform
    esbuild: { jsx: 'transform', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment' },

    // public/ is what the core serves, not Vite's static folder
    publicDir: false,

    build: {
        outDir: 'public/dist',
        emptyOutDir: true,
        lib: {
            entry: 'src/index.jsx',
            formats: ['es'],
            fileName: () => 'index.js',
            cssFileName: 'style'            // fixed output: public/dist/style.css
        },
        rollupOptions: {
            // NOT bundled: resolved by the core's import map
            external: ['react', 'react-dom', 'antd', '@clarive/sdk'],
            // hashed chunks: a rebuild never serves a stale chunk
            output: { chunkFileNames: 'chunks/[name]-[hash].js' }
        }
    }
});

Things to keep in mind:

  • Classic JSX: there is no react/jsx-runtime, so every file with JSX must import React from 'react'.
  • Lib mode: Vite writes the CSS to a separate file and the JS never loads it. The core loads it from frontend.css. If you forget to declare it, the server warns in the log: public/dist/style.css exists but is not declared in feature.yml frontend.css, so it is not loaded.
  • Cache: the entry gets ?v=<mtime> (added by the core) and chunks get a hash (added by Vite).
  • package.json only needs vite and postcss-prefix-selector as devDependencies and the script "build": "vite build". react and antd never end up in the bundle; install them in the feature only for editor autocompletion.

The entry: register(sdk)

The entry module default-exports a register(sdk) function. The core calls it once, on every application startup, to learn which menus, routes and components the feature adds.

import React from 'react';
import { VIEW } from './actions.js';

// Light entry: it is downloaded on EVERY startup. Pages go in separate chunks (React.lazy)
// and the browser only downloads them the first time their route is opened.
const HelloPage = React.lazy(() => import('./HelloPage.jsx'));
const lazyPage = props => (
    <React.Suspense fallback={null}>
        <HelloPage {...props} />
    </React.Suspense>
);

export default function register(sdk) {
    const _ = sdk._;
    const page = ({ idProject }) => lazyPage({ idProject });

    // entry in the "Source" section, in a project and in "All Projects"
    sdk.addSideMenuItem(
        '/code',
        { title: 'Hello React', url: '/code/hello-react', permission: VIEW },
        { contexts: ['project', 'explore'] }
    );
    sdk.addRoute('/code/hello-react', page, { permission: VIEW });

    // own section with sub pages
    sdk.addSideMenuSection({
        title: 'Hello',
        icon: 'experiment',
        url: '/hello-data',
        permission: VIEW,
        contexts: ['project', 'explore'],
        subMenu: [
            { title: _('Summary'), url: '/hello-data/summary' },
            { title: _('Detail'), url: '/hello-data/detail' }
        ]
    });
    sdk.addRoute('/hello-data/summary', page, { permission: VIEW });
    sdk.addRoute('/hello-data/detail', page, { permission: VIEW });
}

Rules of register:

  • All or nothing: while register runs, sdk calls are validated and queued. They are only applied if register finishes successfully and in time. If it throws or times out, no menu or route is left half registered.
  • Immediate validation: a malformed call (a url without /, a missing title, invalid contexts...) throws a TypeError naming the feature, which fails the whole feature. This is on purpose: mistakes show up while developing, not in production.
  • 5 second limit for import() + register(). Features load in parallel. register may return a promise and the core waits for it, but it counts toward those 5 seconds: do not call the backend from register, do it in the pages.
  • Keep the entry small: register and little else; pages with React.lazy.
  • Capability detection: to support older cores, check for the method (if (sdk.addComponent) {...}) or raise the minimum sdk: in feature.yml.
  • Inside register use the sdk parameter. useSdk() does not work there.

Routes: sdk.addRoute(path, render, { permission })

  • render({ query, idProject }) returns a React element. idProject is undefined when the page is opened for all projects (/explore/...). query holds the url parameters.
  • The page is painted inside an error boundary: if it crashes, an error is shown in its place and the rest of the application keeps working.
  • It is also painted inside <div class="cla-feature" data-feature="<id>">, which provides the sdk to useSdk().
  • Without the permission the user gets a 403 page, also when typing the url by hand. Hiding the menu is not enough, which is why the permission goes on the route and on the menu.
Method Use
addSideMenuItem(sectionUrl, item, { contexts }) Add an entry to an existing section that has entries: '/code', '/track', '/deploy'. item = { title, url, permission? } or a function (store) => item.
addTopMenuItem({ title, url, key?, permission? }) Entry in the top menu (after Kanban). key defaults to the first url segment, used to highlight it on its routes.
addSideMenuSection({ title, icon, url, permission?, contexts?, subMenu }) Own section in the side menu. url has a single segment ('/hello'); sub pages must go below it ('/hello/page'). icon is an antd 3 icon name.

Contexts (contexts):

  • 'project' (default): shown in the side menu of a project; the url gets ?idProject=.
  • 'explore': also shown in "All Projects". The /explore/... route is linked automatically to the one registered with addRoute, without idProject. Register the route once, with the project url.

Limitations:

  • Single page sections (such as /settings) cannot be extended: the console shows a warning and the entry is ignored.
  • Side section urls are matched by prefix: a /hello section also captures /hello-react. The core warns in the console when it detects a clash; rename one of them.
  • The titles passed to the core (menus, sections) must be already translated with sdk._(...): the core paints them as they are.

Permissions

Menus, sections, routes and sdk.can() accept three forms of permission:

permission: 'action.hello_react.view'                                  // one action
permission: ['action.hello_react.view', 'action.hello_react.config']   // any of them
permission: permissions => /* bool */                                   // a function
  • The root user can do everything.
  • A permission function that throws denies (fails closed) and is logged in the console.
  • Without permission the entry or route is visible to everybody. sdk.can() always requires a valid permission: an empty or malformed one throws instead of being read as "allowed".
  • This only protects the interface. Every backend endpoint must check its action (see Backend).

Translations (sdk._)

Each feature brings its own dictionaries in i18n/<lang>.po:

msgid ""
msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"

msgid "Saved \"%1\" by %2"
msgstr "Guardado \"%1\" por %2"
  • The server reads the .po of the user's language on every page load and sends it in appData.features[i].i18n. Editing the .po shows up after a reload.
  • sdk._('Clicks: %1', clicks) looks first in the feature's dictionary, then in the core translations, and returns the original text if neither has it. %1..%n are replaced by the arguments.
  • A feature's keys never enter the global _(): a feature cannot change the texts of the core or of another feature.
  • Write msgids in English; English needs no .po.
  • The names of the actions registered in Perl with _locl can also be translated in the feature's .po.

Styles

Loading

  • Stylesheets are declared in frontend.css. The core adds them to the <head> as <link data-feature="<id>"> before loading the JS, so styles are there when the first page paints.
  • If the feature fails, is skipped or times out, its <link>s are removed.
  • If only the CSS fails, the console shows a warning and the feature keeps working without styles.

Isolation

Everything a feature paints is inside <div class="cla-feature" data-feature="<id>">. The convention is that all the feature's CSS hangs from that selector, so it cannot change the look of the core or of other features. There is no need to write it by hand: postcss-prefix-selector adds it at build time.

/* src/hello.css, as you write it */
.hello-page { padding: 24px; }
.ant-card-head-title { color: var(--cla-primary-color); }

/* what ends up in public/dist/style.css */
[data-feature="hello-react"] .hello-page { padding: 24px; }
[data-feature="hello-react"] .ant-card-head-title { color: var(--cla-primary-color); }

This also allows restyling antd components only inside the feature.

Exception, antd popups (Modal, Dropdown, Select, Tooltip...): they are painted in document.body, outside the wrapper, so the prefixed CSS does not reach them. Style them through their className, wrapClassName/dropdownClassName props, or use getPopupContainer to paint them inside the wrapper.

Theme variables

The core publishes its theme as CSS variables, so a feature with plain CSS (no Less) looks the same:

--cla-primary-color        --cla-text-color            --cla-border-color
--cla-link-hover-color     --cla-text-color-secondary  --cla-border-radius-base
--cla-heading-color        --cla-hover-bg              --cla-border-radius-sm
--cla-delete-color         --cla-create-color

They are part of the contract: new ones may be added, but renaming or removing one is a major change.