diff --git a/README.md b/README.md index c07efed..0f32f9a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Motivation Most of the web frontends we are usually building are for a rather small user base to better use or maintain rather complex backends. Many of those systems start out small but over time expand in various directions by different teams using different technologies. Most likely a common scenario for many business support services. -We use micro services and similar technologies to avoid any unnecessary dependencies in the backend, but our users, understandably want a unified UX in the frontend. +We use micro services and similar technologies to avoid any unnecessary dependencies in the backend, but our users, understandably want a unified UX on the frontend. This project is an attempt to achieve that while supporting the independent development of the various parts and components surfacing specific backend capabilites. In other words, we want to minimize the amount of code changes when adding new functionality while still supporting an integrated UX experience. @@ -18,44 +18,69 @@ Let me explain that with a trivial example. Let us assume we just delivered an i Approach --- -We have found the React/Redux approach to be extremely useful in managing dependencies between UX components and cleanly separating state synchronisation between front- and backend. In addition, a purely functional approach to component design not only leads to much cleaner code, but also simplifies testing considerably. +We have found the React/Redux approach to be extremely useful in managing dependencies between UX components and cleanly separating state synchronisation between front- and backend. In addition, a purely functional approach to component design not only leads to much cleaner code, but also simplifies testing considerably. We can visualize this as: -With the positive lessons learned from defining a web page as pure functions over a single state structure, we wanted to see if we can push this further and essentially select those functions through another function over a _UX_ state object. +![Standard Redux](doc/standard-redux.png) -![Basic Idea](doc/high-level.png) -In order to do that, we need to define a _construction_ model for a web page. In _Pihanga_, like in other frameworks, a page is composed of hierarchically nested **cards**. Or, in other words, a _tree of cards_ with the root of the tree being the entire page frame. Each card can contain normal web components as well as other cards. However, we restrict a card to define an embedded card only by a tree-globally unique name. In addition, each card is stateless and it's final presentation and behaviour defined by a set of externally provided properties (which may include the name to be assigned to an embedded card). +With the positive lessons learned from defining a web page as pure functions over a single state structure, we wanted to see if we can push this further and essentially derive the above function _ui()_ itself as the result of another function over the _UX_ state object. -The _Pihanga_ state structure, different to the _Redux state tree_, is a map between the _name_ of a card and it's associated property list. However, the values in that property list can be queries (currently functions) over the entire _Pihanga_ state (all other cards) and the _Redux_ state. +In order to do that, we need to define a _construction_ model for a web page. In _Pihanga_, like in other frameworks, a page is composed of hierarchically nested **cards**. Or, in other words, a _tree of cards_ with the root of the tree being the entire page frame. Each card can contain normal web components as well as other cards. Current practice will not only select the card +to be embedded in another card, but also declare all it's properties. For instance: -Let's demonstrate that on a simple app consisting of a frame-filling `page` card which will show one of two listing cards depending on the `route.routePath` property in the Redux state. +``` +export const FooCard = (props) ==> { + ... + return { + <> + ... + + ... + + } +} +``` + +And that's where _Pihanga_ departs from current practice. Instead of a declaring the exact card to embed, we employ a _late binding_ approach, where the embedded card is only identified by a locally unique identifier. The same code +segment as above in _Pihanga_ looks like: + +``` +export const FooCard = (props) ==> { + ... + return { + <> + ... + + ... + + } +} +``` +where `Card` is essentially a placeholder for a _Pihanaga component_ defined separately. + +The _Pihanga_ state structure, different to the _Redux state tree_, is a map between the `cardName` of a card and it's associated property list. In addition, the values in that property list can be queries (currently functions) over the entire _Pihanga_ state (all other cards) as well as the _Redux_ state. + +Let's demonstrate that on a simple app consisting of a frame-filling `page` card which will show one of two listing cards depending on the `showList` property in the Redux state which is expected to either contain `cars` or `trucks`. The _Pihanga_ state structure is defined as follows: ```javascript -import flow from 'lodash.flow'; -import { pQuery } from '@pihanga/core'card.service'; - export default { page: { cardType: 'AppPage', title: 'Transportation Service', - contentCard: flow( - pQuery(null, 'path', (s) => s.route.routePath), - (a) => a.length == 1 ? a[0].cardName : 'carListing' - ), + subTitle: (_, ref) => ref('.', 'contentCard', 'title'), + contentCard: (state) => state.showList, //... }, - carListing: { - cardType: 'Table', + cars: { + cardType: 'PiTable', title: 'Cars', - path: '/cars', //... }, - truckListing: { - cardType: 'Table', + trucks: { + cardType: 'PiTable', title: 'Trucks', - path: '/trucks', //... }, } @@ -68,25 +93,35 @@ import { Card } from '@pihanga/core'; export const AppPageCard = ({ title, - contentCard, + subTitle, + contentCard, //... }) => { return (
... - + ...
); }; ``` -The `contentCard` property in the `page` card definition shows the use of query to dynamically calculate the property value. `pQuery` returns an query over the _Pihanga_ state. `pQuery(null, 'path', (s) => s.route.routePath)` selects all card defintions (`null` wildcard) where the `path` property (if defined) is equal to the current _Redux_ value `route.routePath`. For the current setup, the query will return zero or one records which is reduced in the second function to `flow` either the name of the single matching card or a default card. +In this simple example the mapping between `` and the actual card embedded is determined by the +value bound to the `contentCard` property of of the _Pihanga state_ of the card (`page`) embedding it. That value can either be a +constant (`title: 'Cars'`), a reference to the property value of another card (`... => ref('.', 'contentCard', 'title')`), or a function on the current _Redux_ state (`... => state.showList`). + +Simply changing the value of `showList` to a different value, will not only change what card is being embedded inside `AppPageCard`, but also the `subTitle` that card is displaying. Beside the dynamic nature of this approach, we can now also +develop 'template' cards which can dynamically be adapted to various different uses __without__ having to change their internals. + +## Further Reading +The [examples](./examples) directory contains various examples on how _Pihanga_ can be used to quickly develop useful and scalable web applications. See the _README_ files in the various sub directories for more information. +The -# Developer Notes +## Developer Notes -## Pulishing new release +### Pulishing new release lerna version prerelease lerna publish from-git diff --git a/doc/standard-redux.png b/doc/standard-redux.png new file mode 100644 index 0000000..dabc1ab Binary files /dev/null and b/doc/standard-redux.png differ diff --git a/examples/micro-service/doc/wireframes.bmpr b/examples/micro-service/doc/wireframes.bmpr index cd7fc1b..4e5e09b 100644 Binary files a/examples/micro-service/doc/wireframes.bmpr and b/examples/micro-service/doc/wireframes.bmpr differ diff --git a/examples/real-time-charts/README.md b/examples/real-time-charts/README.md index 9429d9c..5d48124 100644 --- a/examples/real-time-charts/README.md +++ b/examples/real-time-charts/README.md @@ -37,102 +37,107 @@ declares all the used cards, their properties and how it fits together. But before we have a look at that, we need to understand the workflow implemented by this app. -# FIX ME +## Single Page App -As mentioned in the overview the app takes a user through three different step, `passport`, `spinner`, and `answer`. The currently active step is stored in the `step` property of the _Redux_ state (see [app.initial-state.js](src/app.initial-state.js)). +We build a very simple app with titled page containing a single grid layout card for the content, which in turn holds two cards dispaying a line chart, one for CPU and one for memory use. -Now let us get back to [app.pihanga.js](src/app.pihanga.js) and look at the entry point `page`. +![Wireframe](doc/wireframe.png) - page: { - cardType: 'PiSimplePage', - contentCard: s => s.step, - ... - }, - -`PiSimplePage` is a card provided by the standard __Pihanga__ library and provides ust minimal scaffolding for displaying a single child card identified through `contentCard`. In our case we -simply use the workflow `step` from the _Redux_ state. + const page = { + page: { + cardType: 'PiPageR1', + contentCard: 'graphs', + title: 'Realtime Charts', + footer: {copyright: 'The Pihanga Team'} + }, -The initial workflow state is `passport` and indeed we find a `passport` entry in [app.pihanga.js](src/app.pihanga.js): + graphs: { + cardType: 'PiGrid', + spacing: 3, + content: ['cpuGraph', 'memoryGraph', ], + }, - passport: { - cardType: 'PiTitledPage', - contentCard: 'form', - ... - }, - - form: { - cardType: 'PiForm', - title: 'Ask the Ring', - submitLabel: 'Send Query', - fields: [ + cpuGraph: { + cardType: 'WrappedCard', + title: 'CPU User', ... - ], - ... - }, + }, -![Passport Page](doc/passport.png) - -The `passport` page consists of a titled page and an embedded form (`form`). Pressing the 'submit' button will trigger the default action: - - { - type: "PI_FORM:FORM_SUBMIT" - id: "form" - passport: "44444" - question: "0" - ring: "0" + memoryGraph: { + cardType: 'WrappedCard', + title: 'Memory', + ... + } + }; + +The first two card definitions are rather straight forward, while the +card type `WrappedCard` is a locally defined _Meta Card_. A _Meta Card_ does not represents any actual cards, but will instead return a named list of new cards which are dynamically inserted into the card declaraton list. Please note, that any of the returned cards can be a +_Meta Card_ leading to a recursive expansion. + +Now lets have a look at the definition of `WrappedCard` which we can +find at the beginning of [app.pihanga.js](src/app.pihanga.js): + +``` +export function init(register) { + ... + register.metaCard('WrappedCard', (name, defs) => { + const { + cardType, title, + yLabel, metricsType, maxY = 100, + ...inner + } = defs; + const innerName = `${name}-inner`; + const h = {}; + h[name] = { + cardType: 'MuiCard', + title, + contentCard: innerName, } - -which is 'reduced' in [workflow.js](src/workflow.js): - - register.reducer(actions('PiForm').FORM_SUBMIT, (state, action) => { - dispatchFromReducer(() => { - getPassportCount(action.passport); - }); - const s = update(state, ['step'], 'spinner'); - return update(s, ['question'], action.question); - }); - -The reducer first initiates an API call `getPassportCount`, and then changes both the `step`, as well -as the `answer` property of the _Redux_ state. Changing the `step` property to `spinner` will, according -to the `page.contentCard` declaration in [app.pihanga.js](src/app.pihanga.js), now display the `spinner` card -which is defined as: - - spinner: { - cardType: 'Spinner', - ... + h[innerName] = { + cardType: 'ReLineChart', + data: s => s.metrics[metricsType], + ... + } + return h; + }); +} +``` + +## Realtime Updates + +[backend.js](src/backend.js): +``` +const METRICS_URL = '/metrics?after=:after'; +const UPDATE_INTERVAL_MS = 2000; + +export function init(register) { + registerPeriodicGET({ + name: 'getMetrics', + url: METRICS_URL, + intervalMS: UPDATE_INTERVAL_MS, + + start: '@@INIT', + init: (state) => { + const m = {metrics: {...}}; + return update(state, [], m); }, -'Spinner' is an application specific card and defined in the [spinner](src/spinner) directory. - -![Spinner Page](doc/spinner.png) - -The above referenced `getPassportCount` function will dispatch a `UPDATE_PASSPORT` event on successful -completion of the API request, which in turn is reduced in [workflow.js](src/workflow.js): - - register.reducer(ACTION_TYPES.UPDATE_PASSPORT, (state, action) => { - const s = update(state, ['step'], 'answer'); - return update(s, ['answer'], action.reply); - }); - -The first update is setting the step to `answer` which in turn will display the `answer` card defined in -[app.pihanga.js](src/app.pihanga.js) as follows: - - answer: { - cardType: 'Answer', - answer: s => s.answer, - question: s => s.question, + request: (state) => { + const lastTS = state... + return {after: lastTS}; }, + reply: onMetricsUpdate, + }); +} -As with the `Spinner` card type, `Answer` is also an app specific card and defined in the [answer](src/answer) directory. It's properties `answer` and `question` are bound to the equally named properties in _Redux_. +function onMetricsUpdate(state, reply) { + ... + return update(state, ['metrics'], metrics); +} +``` -![Answer Page](doc/answer.png) -Finally, the action `NEW_REQUEST` associated with the `NEW REQUEST` button on the answer page is reduced in -[workflow.js](src/workflow.js) to return to the `passport` page: - register.reducer(ANSWER_TYPES.NEW_REQUEST, (state) => { - return update(state, ['step'], 'passport'); - }); diff --git a/examples/real-time-charts/doc/wireframe.png b/examples/real-time-charts/doc/wireframe.png new file mode 100644 index 0000000..9a81f5f Binary files /dev/null and b/examples/real-time-charts/doc/wireframe.png differ diff --git a/examples/real-time-charts/doc/wireframes.bmpr b/examples/real-time-charts/doc/wireframes.bmpr new file mode 100644 index 0000000..38f963d Binary files /dev/null and b/examples/real-time-charts/doc/wireframes.bmpr differ diff --git a/examples/real-time-charts/src/rest_client/fetch-api.js b/examples/real-time-charts/src/rest_client/fetch-api.js new file mode 100644 index 0000000..fefb479 --- /dev/null +++ b/examples/real-time-charts/src/rest_client/fetch-api.js @@ -0,0 +1,183 @@ +import 'whatwg-fetch'; +import { backendLogger } from './backend.logger'; +import { + throwUnauthorisedError, + throwPermissionDeniedError, +} from './rest.actions'; +import { getCookie } from './browser-cookie'; +//import { dispatch } from '../redux'; + +const Config = { + API_BASE: '', + AUTH_TOKEN_COOKIE_NAME: undefined, //'AUTH_TOKEN', + // The value of this header will be checked by server. If missing, server will return 401 for + // restricted access API + AUTH_TOKEN_HEADER: undefined, // 'N1-Api-Key', +}; + +/** + * Common API request properties + * @type {{headers: {Content-Type: string}, credentials: string}} + */ +export const API_REQUEST_PROPERTIES = { + headers: { + 'Content-Type': 'application/json', + }, + + credentials: 'include', +}; + +export function config(config) { + for (var k of Object.keys(Config)) { + if (config[k]) { + Config[k] = config[k]; + } + } +} + +/** + * Unwrap data + * @param response + * @returns {Object} + */ +function unwrapData(response) { + // Handle no content because response.json() doesn't handle it + if (response.status === 204) { + return {}; + } + + return response.json(); +} + +/** + * Check the response from the server + * Log and throw the error if response status is a HTTP error code, since client code might be + * interested to deal with these errors + * + * @param url + * @param response + * @param silent If true, there won't be any logging + * @returns {*} + * @throws Error + */ +async function checkStatusOrThrowError(url, response, silent) { + if (response.status >= 200 && response.status < 300) { + return response; + } + + // don't throw or log any error + if (!silent) { + if (response.status === 401) { + throwUnauthorisedError(); + } else if (response.status === 403) { + throwPermissionDeniedError(); + } + + backendLogger.error(`Request for '${url}' failed`, response); + } + + // Client code might be interested in doing something with the error, and the original response + const error = new Error(response.statusText); + + try { + error.response = await response.json(); + error.status = response.status; + } catch (e) { + // ignoring the error of getting json data from response + error.response = response; + } + + throw error; +} + +/** + * @param apiUrl Should contain a leading "/" + * @param request + * @param silent If true, there won't be any logging + * @returns {Promise} - NOTE: Error has been logged + */ +export function fetchApi(apiUrl, request, silent) { + const fullApiUrl = `${Config.API_BASE}${apiUrl}`; + + // Need to stringtify JSON object + const tmpRequest = request; + if (tmpRequest && tmpRequest.body && typeof (tmpRequest.body) !== 'string') { + tmpRequest.body = JSON.stringify(tmpRequest.body); + } + + const requestProperties = { + ...API_REQUEST_PROPERTIES, + ...tmpRequest, + }; + + if (Config.AUTH_TOKEN_COOKIE_NAME) { + const apiAuthToken = getCookie(Config.AUTH_TOKEN_COOKIE_NAME); + if (apiAuthToken) { + requestProperties.headers[Config.AUTH_TOKEN_HEADER] = apiAuthToken; + } + } + + // NOTE: The Promise returned from fetch() won't reject on HTTP error status even if the response + // is an HTTP 404 or 500 + return fetch(fullApiUrl, requestProperties) + .then(response => checkStatusOrThrowError(fullApiUrl, response, silent)) + .then(unwrapData); +} + +/** + * @param error + * @returns {boolean} True if there is a problem connecting to the API + */ +export function isConnectionError(error) { + const INTERNAL_FETCH_ERROR = 'Failed to fetch'; + return error && error.message === INTERNAL_FETCH_ERROR; +} + +// /** +// * Returns a convenience function for common backend interaction. It starts by +// * dispatching an action of type `getAction`. It then sends a GET request +// * to a specific url and dispatches the result in an action of type `replyAction`. +// * If the http request fails, an action with type `errorAction` is dispatched. +// * +// * The result of the http request is added to the `replyAction` under the `result` +// * key, while the error is added under the `error` key. +// * +// * If the first parameter is a string, then it is used for any subsequent requests. +// * However, if the first parameter is a function, then this function is called with +// * all paramters provided to the activating function and is expected to return the +// * calling url as a string. In addition, the first parameter to the activating function +// * is interpreted as an `id` and is added to all actions under the `id` key. +// * +// * @param {string|function} urlOrFunc +// * @param {string} getAction +// * @param {string} replyAction +// * @param {string} errorAction +// */ +// export function backendGET(urlOrFunc, getAction, replyAction, errorAction) { +// const isFunc = urlOrFunc instanceof Function; +// return (id, ...args) => { +// const url = isFunc ? urlOrFunc(id, ...args) : urlOrFunc; + +// const p = { type: getAction }; +// if (id) p.id = id; +// dispatch(p); + +// fetchApi(url, { +// method: 'GET', +// }).then((reply) => { +// const p = { +// type: replyAction, +// reply, +// }; +// if (id) p.id = id; +// dispatch(p); +// }).catch(error => { +// const p = { +// type: errorAction, +// error, +// } +// if (id) p.id = id; +// dispatch(p); +// }); +// } +// } diff --git a/examples/real-time-charts2/src/rest_client/fetch-api.js b/examples/real-time-charts2/src/rest_client/fetch-api.js new file mode 100644 index 0000000..d58f32a --- /dev/null +++ b/examples/real-time-charts2/src/rest_client/fetch-api.js @@ -0,0 +1,183 @@ +import 'whatwg-fetch/dist/fetch.umd'; +import { backendLogger } from './backend.logger'; +import { + throwUnauthorisedError, + throwPermissionDeniedError, +} from './rest.actions'; +import { getCookie } from './browser-cookie'; +//import { dispatch } from '../redux'; + +const Config = { + API_BASE: '', + AUTH_TOKEN_COOKIE_NAME: undefined, //'AUTH_TOKEN', + // The value of this header will be checked by server. If missing, server will return 401 for + // restricted access API + AUTH_TOKEN_HEADER: undefined, // 'N1-Api-Key', +}; + +/** + * Common API request properties + * @type {{headers: {Content-Type: string}, credentials: string}} + */ +export const API_REQUEST_PROPERTIES = { + headers: { + 'Content-Type': 'application/json', + }, + + credentials: 'include', +}; + +export function config(config) { + for (var k of Object.keys(Config)) { + if (config[k]) { + Config[k] = config[k]; + } + } +} + +/** + * Unwrap data + * @param response + * @returns {Object} + */ +function unwrapData(response) { + // Handle no content because response.json() doesn't handle it + if (response.status === 204) { + return {}; + } + + return response.json(); +} + +/** + * Check the response from the server + * Log and throw the error if response status is a HTTP error code, since client code might be + * interested to deal with these errors + * + * @param url + * @param response + * @param silent If true, there won't be any logging + * @returns {*} + * @throws Error + */ +async function checkStatusOrThrowError(url, response, silent) { + if (response.status >= 200 && response.status < 300) { + return response; + } + + // don't throw or log any error + if (!silent) { + if (response.status === 401) { + throwUnauthorisedError(); + } else if (response.status === 403) { + throwPermissionDeniedError(); + } + + backendLogger.error(`Request for '${url}' failed`, response); + } + + // Client code might be interested in doing something with the error, and the original response + const error = new Error(response.statusText); + + try { + error.response = await response.json(); + error.status = response.status; + } catch (e) { + // ignoring the error of getting json data from response + error.response = response; + } + + throw error; +} + +/** + * @param apiUrl Should contain a leading "/" + * @param request + * @param silent If true, there won't be any logging + * @returns {Promise} - NOTE: Error has been logged + */ +export function fetchApi(apiUrl, request, silent) { + const fullApiUrl = `${Config.API_BASE}${apiUrl}`; + + // Need to stringtify JSON object + const tmpRequest = request; + if (tmpRequest && tmpRequest.body && typeof (tmpRequest.body) !== 'string') { + tmpRequest.body = JSON.stringify(tmpRequest.body); + } + + const requestProperties = { + ...API_REQUEST_PROPERTIES, + ...tmpRequest, + }; + + if (Config.AUTH_TOKEN_COOKIE_NAME) { + const apiAuthToken = getCookie(Config.AUTH_TOKEN_COOKIE_NAME); + if (apiAuthToken) { + requestProperties.headers[Config.AUTH_TOKEN_HEADER] = apiAuthToken; + } + } + + // NOTE: The Promise returned from fetch() won't reject on HTTP error status even if the response + // is an HTTP 404 or 500 + return fetch(fullApiUrl, requestProperties) + .then(response => checkStatusOrThrowError(fullApiUrl, response, silent)) + .then(unwrapData); +} + +/** + * @param error + * @returns {boolean} True if there is a problem connecting to the API + */ +export function isConnectionError(error) { + const INTERNAL_FETCH_ERROR = 'Failed to fetch'; + return error && error.message === INTERNAL_FETCH_ERROR; +} + +// /** +// * Returns a convenience function for common backend interaction. It starts by +// * dispatching an action of type `getAction`. It then sends a GET request +// * to a specific url and dispatches the result in an action of type `replyAction`. +// * If the http request fails, an action with type `errorAction` is dispatched. +// * +// * The result of the http request is added to the `replyAction` under the `result` +// * key, while the error is added under the `error` key. +// * +// * If the first parameter is a string, then it is used for any subsequent requests. +// * However, if the first parameter is a function, then this function is called with +// * all paramters provided to the activating function and is expected to return the +// * calling url as a string. In addition, the first parameter to the activating function +// * is interpreted as an `id` and is added to all actions under the `id` key. +// * +// * @param {string|function} urlOrFunc +// * @param {string} getAction +// * @param {string} replyAction +// * @param {string} errorAction +// */ +// export function backendGET(urlOrFunc, getAction, replyAction, errorAction) { +// const isFunc = urlOrFunc instanceof Function; +// return (id, ...args) => { +// const url = isFunc ? urlOrFunc(id, ...args) : urlOrFunc; + +// const p = { type: getAction }; +// if (id) p.id = id; +// dispatch(p); + +// fetchApi(url, { +// method: 'GET', +// }).then((reply) => { +// const p = { +// type: replyAction, +// reply, +// }; +// if (id) p.id = id; +// dispatch(p); +// }).catch(error => { +// const p = { +// type: errorAction, +// error, +// } +// if (id) p.id = id; +// dispatch(p); +// }); +// } +// } diff --git a/examples/theme/README.md b/examples/theme/README.md new file mode 100644 index 0000000..d7c28eb --- /dev/null +++ b/examples/theme/README.md @@ -0,0 +1,8 @@ + +Pallete: + +#8E30B0 (purple) #CD5263 (redish) #F8E54C (yellow) +#D44B45 (redish) #52B45C (green) #8776CB (purple) +#F09D38 (yellow) #8ECA3D (green) #5D45B5 (purple) +#F8D949 (yellow) #0F0399 (blue) #67AB33 (green) + diff --git a/packages/material/src/card/pageR1/pageR1.component.jsx b/packages/material/src/card/pageR1/pageR1.component.jsx index 29017f3..01404bd 100644 --- a/packages/material/src/card/pageR1/pageR1.component.jsx +++ b/packages/material/src/card/pageR1/pageR1.component.jsx @@ -199,7 +199,7 @@ export const PageR1Component = styled(({ } return ( - <> +
{ addSidePanel() } @@ -228,6 +228,6 @@ export const PageR1Component = styled(({ { addFooter() } - +
); }); diff --git a/packages/material/src/card/pageR1/pageR1.style.js b/packages/material/src/card/pageR1/pageR1.style.js index 63ddbd2..4ab2160 100644 --- a/packages/material/src/card/pageR1/pageR1.style.js +++ b/packages/material/src/card/pageR1/pageR1.style.js @@ -21,6 +21,11 @@ export default withStyles((theme) => { backgroundColor: theme.palette.common.backgroundColor, }, }, + outer: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, content: { width: '100%', flexGrow: 1, diff --git a/packages/material/src/root/root.component.jsx b/packages/material/src/root/root.component.jsx index 880502f..3296110 100644 --- a/packages/material/src/root/root.component.jsx +++ b/packages/material/src/root/root.component.jsx @@ -7,6 +7,6 @@ export const RootComponent = () => { reloadBackend(); return ( - + ); };