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 mustimport 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.jsononly needsviteandpostcss-prefix-selectoras devDependencies and the script"build": "vite build".reactandantdnever 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
registerruns, sdk calls are validated and queued. They are only applied ifregisterfinishes 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 missingtitle, invalidcontexts...) throws aTypeErrornaming 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.registermay return a promise and the core waits for it, but it counts toward those 5 seconds: do not call the backend fromregister, 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 minimumsdk:infeature.yml. - Inside
registeruse thesdkparameter.useSdk()does not work there.
Routes: sdk.addRoute(path, render, { permission })¶
render({ query, idProject })returns a React element.idProjectisundefinedwhen the page is opened for all projects (/explore/...).queryholds 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 touseSdk(). - 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.
Menus and sections¶
| 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 withaddRoute, withoutidProject. 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
/hellosection 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 withsdk._(...): 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
permissionthe 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
.poof the user's language on every page load and sends it inappData.features[i].i18n. Editing the.poshows 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..%nare 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
_loclcan 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.