Overview
As with our use of HTML, rendered markup needs to serve more than one purpose, and we do this using Javascript logic. The Javascript language has evolved over the years with more features and conveniences being made available at a very fast pace. In order to make use of these language enhancements while also respecting browser compatibility, we manipulate our Javascript by running it through the Babel transpiler for target legacy browsers, and adding specific language feature polyfills where they make a difference to the developer experience of creating composable components.
The Tui framework uses a fairly standard ESLint configuration to enable developers with make programming decisions that are both considerate of browser performance and follow a general style approach. There are escape hatches when needed, as there are sometimes exceptions to rules.
We use ES Modules as our module system and Webpack as our module bundler, with some custom extensions to allow for cross-bundle module usage. Each Vue component is an ES module.
Implementation
Locations and importing
All modules (including JS files and Vue components) must be in a specified subdirectory of the src folder of a Tui component in the client directory.
The supported subdirectories are js, pages, and components. Modules within the js folder are importable as component_name/myfile
, whereas the other two require their folder name in the import path, e.g. component_name
/components/Example
. Omit the file extension when importing.
Relative imports may be used for JS files, but absolute imports starting with the component name must always be used for Vue components, otherwise the import will not pick up any overrides from the theme.
Use the ES import syntax for importing files:
import { url } from 'totara_core/util'; import FlexIcon from 'totara_core/components/icons/FlexIcon'; import someLib from './some_lib';
Cross-Tui-component usage
There are some special requirements if you want to use a Vue component or JavaScript module from another Tui component - e.g. using a Vue component from totara_job
in totara_dashboard
. If it is a static dependency (i.e. you know what component you need ahead of time), you can add it to dependencies
in tui.json, e.g. "dependencies": ["totara_job"]
and that bundle will be loaded alongside the bundle for your Tui component.
If you don't know what component you want ahead of time, for example if its name is returned by an API call, or want to delay loading the bundle it is contained in until it is actually used, there are a couple of options.
You can call the async function tui.import()
with the name of the module and it will resolve with the module once it has been loaded. This will return all exports of the module. To get the default export you can use tui.defaultExport()
on the result, or .default
if it is an ES module.
For Vue components, the preferred option is to use tui.asyncComponent()
. This makes use of Vue's async components feature to immediately return an object that can be rendered as a component immediately, rendering a loading spinner until the real component is loaded. tui.asyncComponent()
will also automatically load language strings for the component.
Another option is to call the async function tui.loadComponent()
, which returns a promise resolving to the component.
<template> <component :is="component" /> </template> <script> export default { data: { component: null }, methods: { showComponent() { const name = 'mod_foo/components/Example'; this.$options.components[name] = tui.asyncComponent(name); this.component = name; }, async callJs() { const someLib = await tui.import('mod_foo/some_lib'); someLib.doThing(); } }; </script>
If your Tui component has a dependency on another Tui component (whether static or async), you should also specify a dependency in the corresponding PHP Totara plugin.
JavaScript utilities
We provide a library of utility functions as part of the Tui core code.
They are well commented so the best place to get the full details is in the code, but here is an overview of the major ones:
- tui/util: Array helpers like
groupBy()
, object helpers likestructuralDeepClone()
, and function helpers likememoize()
anddebounce()
.totaraUrl()
, which generates a URL to a page on Totara, can also be imported from here - tui/config: General page configuration info like the web root, session key, and current theme
- tui/notifications: Show a notification toast message for a successful action
- tui/theme: Functions for interacting with the theme, e.g. getting CSS variable values
- tui/errors: Error display code
- tui/accessibility: Accessibility helpers
- tui/pending: Signal to behat that we are waiting for something
- tui/dom/focus: Control focus
- tui/dom/position: Get the position of elements on the page
- tui/dom/transitions: Wait for a transition
Asynchronous code
In general, the recommended way to write async code is using the ES6 async/await feature. You can also use Promises, which async/await use under the hood.
The use of callbacks for asynchronous functionality is strongly discouraged in favour of async/await and Promises as it makes for much more maintainable code.
Loading data in components
Data loading in components is done over GraphQL. You can use the tui/components/loader/Loader component to display a loading spinner while content is loading.
It's recommended to base the rendering state on whether the query result is populated yet rather than $apollo.loading
, as $apollo.loading
will be false if an error occurs, and will be true again if the query re-fetches for any reason. However, you can combine this with checking $apollo.loading
in the condition too so that the loader doesn't stay visible if the query fails.
For example:
<template> <Loader :loading="!ping && $apollo.loading"> <div v-if="ping">{{ ping.status }}</div> </Loader> </template> <script> import Loader from 'tui/components/loader/Loader'; import statusQuery from 'totara_webapi/graphql/status_nosession'; export default { components: { Loader, }, apollo: { ping: { query: statusQuery, update: data => data.totara_webapi_status, }, }, }; </script>
Tips and known limitations
Polyfills
We use Babel to compile modern (ES6 and later) JavaScript syntax to ES5 in a separate bundle that is served to IE 11. This only covers syntax and language features, so it's important to check the browser support for any JS APIs you use to make sure they are available in the supported browsers or polyfilled (for IE11).
IE 11 limitations
There are some limitations to be aware of when writing code that needs to work with IE 11.
Aside from JS APIs, the following JS language features do not work in either IE:
for...of
can not be polyfilled in IE 11 in a way that is both performant and standards-compliant- Generators do technically work but will be flagged by the linter as they perform poorly in IE 11
If you are using Totara 13-15 (which supports Edge Legacy 16-18), the following language features cannot be used:
- Object spread is not supported by Edge Legacy. Array spread and parameter spread are fine
These JS APIs are supported but have some pitfalls:
Object.keys()
with a non-object argument throws an exception in IE, so make sure to only call it with an object.- Promise is supported via Polyfill in IE 11, but due to the aforementioned lack of micro-task support, it may not behave exactly as in other browsers, which can expose race condition bugs with user events that don't happen in other browsers. However, this is rare and the solution is usually to improve the robustness of your code as there is usually an underlying problem if you run into this.
IE 11 lacks support for executing JS in micro-tasks, which can result in slightly degraded performance of asynchronous code and updates to Vue components being delayed until after native events are processed. This is a platform-level feature that is not possible to polyfill or emulate.