4 Frontend framework
This documentation section contains documentation for the frontend framework that Oskari frontend provides and what it means for applications that are built using oskari-frontend.
Oskari frontend source code has the following folder structure:
/api
- The documentation for bundles and API they provide with a change log of changes to the API/bundles
- Implementation files for built-in bundles/resources
- Common CSS styles/images/src
- Code for Oskari frameworksrc/react
- UI-components library
/tools
- Random templates and scripts for generating CSV-files based on localization/webpack
- Helpers and configurations for current build tools/libraries
- Older jQuery plugins and other dependencies/libraries that are not reasonably available through npm- (
/packages
) - Legacy-definition files for bundles (content is being migrated to bundles with new syntax).
The main folders are:
bundles
(functionality implementations you can use in applications)src
(for framework code)webpack
(for build scripts)
Note! We have started migrating the contents under packages
folder to bundles
with a newer format. These two formats require different Webpack-loaders when referenced from the application main.js
file.
The folder structure for bundles
follows a pattern where the first folder under the base folder is a namespace
folder. Oskari uses framework
and mapping
namespaces for most of the bundles and admin
for admin tools. The namespace is purely cosmetic and is for grouping the bundles/organizational purposes. When creating app-specific bundles you don't have to use a namespace but you can if you wish. The next folder after the namespace is named after the {bundle-identifier}
. Note that under the packages
folder there can be a folder with the name bundle
in between.
4.1 Oskari global and sandbox
The Oskari frontend framework functionality is located in the src
folder in oskari-frontend repository.
The folder also includes React.js based UI-component library (based on AntD-components).
4.1.1 Oskari global
A global Oskari
variable is introduced for Oskari frontend applications to access the framework functionalities.
It provides some functionalities that applictions can use (bundles usually) like:
- application environment like:
- current language
Oskari.getLang()
- supported languages
Oskari.getSupportedLanguages()
andOskari.getDefaultLanguage()
- setup configuration
Oskari.app.getApplicationSetup()
- type
Oskari.app.getType()
and uuidOskari.app.getUuid()
- theme
Oskari.app.getTheming().getTheme()
andsetTheme()
- current language
- instance customization:
- marker selection
Oskari.getMarkers()
andOskari.getDefaultMarker()
- default app setups
Oskari.app.getSystemDefaultViews()
- urls
Oskari.urls.getRoute('route name')
- marker selection
- bundle registry
Oskari.bundle()
andOskari.lazyBundle()
- sandbox registry (see below)
Oskari.getSandbox()
- app/bundle lifecycle handling
Oskari.on('bundle.start')
andOskari.on('app.start')
- tracking of current user
Oskari.user()
- access to localizations
Oskari.getMsg(locId, locKey, params)
- class system (being migrated away in favor of ES-classes)
Oskari.clazz.define()
and.create()
- helper functions:
- for colors etc
Oskari.util.hexToRgb('#FFAA33')
- number formatting
Oskari.getNumberFormatter()
- coordinate formatting
Oskari.util.coordinateMetricToDegrees()
- sequence tracking
Oskari.getSeq('sequenceId').curVal()
and.nextVal()
- url building
Oskari.urls.buildUrl('https://mydomain/path', { paramName: 'value' })
- for colors etc
- DOM element helpers like
Oskari.dom.getNavigationEl()
- logging functionality
Oskari.log('loggername').warn('some warning')
4.1.2 Sandbox
Sandbox is:
- event/message bus for frontend bundles to communicate through requests and events etc
sandbox.postRequestByName('AddMapLayerRequest', [...params]);
sandbox.notifyAll(someEvent)
sandbox.addRequestHandler(someReqName, someHandler)
sandbox.registerForEventByName('AfterMapLayerAddEvent')
- registry for modules
sandbox.register(this)
(required for listening to events)sandbox.findRegisteredModuleInstance()
- stateful bundle registry
registerAsStateful(bundleId, bundleInstance)
- registry for services
sandbox.registerService(someService)
andsandbox.getService(serviceName)
- conveniency getters for map state:
sandbox.getMap()
sandbox.findAllSelectedMapLayers()
- functionality context for scoping events/requests
There can be multiple sandboxes in an Oskari frontend application to scope messaging, but most commonly there is just one that can be accesssed with Oskari.getSandbox()
.
The function takes a string-parameter enabling multiple sandbox instances to be used but it's very uncommon to use other than the default sandbox.
Bundle instances get a sandbox reference as parameter on their start(sandbox)
function and they should save and use that reference.
This is handled by BasicBundleInstance
base class that also provides getSandbox()
function as a way of using the given sandbox.
This enables scoping the bundle interactions that could be used for having two instances of for example the mapmodule on the same page/Oskari application.
Note that some bundles might use the Oskari
global to get a sandbox reference (effectively defeating the purpose), but this is not intended use.
See Bundle API for details about messaging between bundles.
4.2 Bundles
A bundle can be considered as a building block in an Oskari application. A bundle provides some documented functionality and optionally an API that it offers to other bundles for interaction purposes. A bundle has a bundle id
that is used as a name for the documention of the functionality and the API that the bundle provides, but can have multiple parallel implementations with the idea that switching between implementations is a drop-in replacement. An example of this could be a 2D (OpenLayers) and a 3D (Cesium) implementation of the map functionality (mapmodule
). They both integrate with the rest of the application by providing and using the same documented API, but offer a different experience for the end-user.
The implementation details of a bundle should not really matter, but the API and the functionality they provide should be documented and either be backwards compatible or any changes need to be documented in the changelog. Notice that changes to the API can be very hard to manage for developers that use it to control embedded maps that are published from Oskari-based services that they themselves don't control. The API changes for the embedded maps at that exact time when the Oskari instance is updated with new version. It is good to keep in mind when designing API changes.
A comprehensive versioned documentation of all available bundles in oskari-frontend
can be found here (generated from the api-folder).
4.2.1 Bundle implementation
Minimal implementation of a bundle is just having a factory function for the bundle that returns an instance of the bundle implementation:
import { BasicBundleInstance } from 'oskari-ui/BasicBundleInstance';
class MyBundleInstance extends BasicBundleInstance {
start (sandbox) {
super.start(sandbox);
console.log('Hello world')
}
}
Oskari.bundle('hello-world', () => new MyBundleInstance());
Here the Oskari.bundle()
function is used to register the factory function for bundle id hello-world
.
You could save the code to oskari-frontend/bundles/sample/hello-world/index.js
to have it accessible for any Oskari-based applications.
In the path:
bundles
is the folder that holds all the bundle implementationssample
would be a namespace for organizing/grouping bundles (you can skip this on application specific bundles)hello-world
should match the bundle id (just to make things easier to find, but is not a requirement)
To use this in an application you would import it to your applications main.js
with:
import 'oskari-bundle!oskari-frontend/bundles/sample/hello-world';
Or you could save it application specific bundle as your-frontend-repo/anywhere_really/whatever.js
and just update the reference for the import on
your-frontend-repo/applications/myapp/main.js
:
import 'oskari-bundle!../../anywhere_really/whatever.js';
After that the bundle with id hello-world
can be started as part of your application.
A good practice is to separate the bundle instance to its own file and import it on the index.js
file so the factory file stays as clean and simple as it can.
Note! Making the bundle accessible in your application with import on main.js
only means that the code is packaged as part of the javascript file that is loaded by the end-users browser.
It doesn't mean it gets run/started on your application.
4.2.2 Starting a bundle as part of an application
To start a bundle as part of the application it needs to be included in the javascript file that is loaded by the end-users browser. This is done by importing it on the main.js
file of the application.
To actually start an included bundle, there are two choices:
- have the bundle be referenced in the
GetAppSetup
response from the server (recommended) - for testing you can start it in the developer console with
Oskari.app.playBundle({ bundlename: 'hello-world' })
Applications usually have an index.js
file that fetches the GetAppSetup
response from the server with Oskari.app.loadAppSetup()
.
4.2.3 Bundle lifecycle
When a bundle is started by the framework:
1) the factory function is called to create an instance
for the bundle.
2) after creating an instance, variables are injected into the instance object:
instance.mediator = {
bundleId,
instanceId
}
Where:
bundleId
is the id you expect (hello-world
in the example)instanceId
is something that can be used to differentiate multiple instances of the same bundle in a more complex application (defaults to bundle id).
Oskari.app.getConfiguration()
has configuration for all of the bundles in the application and is usually
populated from the GetAppSetup
response from the server with Oskari.app.loadAppSetup()
. This configuration has keys matching bundle instanceIds (defaults to bundle ids)
with an object as value.
The configuration object can have conf
and/or state
keys and these are injected as-is to the instance object as variables.
The values under conf
usually have configurations that don't change at runtime (for map this could be for example the zoom levels or projection).
The values under state
have settings that can change at runtime (for map this could be the center coordinate and layers that are on the map).
Bundles that use these have code that refer to this.conf
or this.state
for handling these and they are also documented in the bundle documentation.
3) The start()
function is called by the framework when the bundle is started as a part of an application.
The start-function receives a reference to the Oskari sandbox as parameter and if you pass that to BasicBundleInstance
base class with super.start(sandbox)
it is accessible with this.getSandbox()
in the other functions you might want to implement on your bundle.
4) The stop()
function can be called by for example the publisher functionality
The bundle should do any cleanup in the stop-function like stop listening to events, unregister itself and any request handlers it has added etc.
4.2.4 Bundle architecture
There's a couple pf concepts that are used with bundles implemented in oskari-frontend
that you can use when implementing any application specific bundles as well.
Handler
"Service" in the image above. It's the Handler's responsibility keep the state related to the bundle business logic consistent and updated. Possibly saving this state to the backend via action routes when needed. The Handler exposes public methods as Controllers to mutate the state and allows other components within the bundle to register for notifications about state changes.
Controller
Controller is created as a subset of functions from Handler and only has implicitly exposed functions from Handler that allows manipulate the state with new values.
View
It's the View's responsibility to update the user-interface DOM accordingly when it receives notification from the Handler that state has changed. View is passed the current state with a Controller and it's the View's responsibility to use the Controller to mutate state as a reaction to user input. Flyouts, Tiles, Popups etc. are parts of the View.
Map Plugin
It's the Map Plugin's responsibility to update the map related presentation when it receives notification from the Handler that state has changed. If the bundle does not have map related functionality, it doesn't need to implement a Map Plugin. Map layers, map controls, map interactions are implemented by Map Plugins.
Data flow
User input -> View/Plugin mutates state by calling Controller functions -> Controller as part of Handler updates internal state (possibly saving to backend) -> Handler notifies all interested components by triggering an event -> Listening components (Views & Map Plugins) update their presentation.
stateDiagram-v2 direction LR [*] --> View: Initial state View --> Controller: User interaction Controller --> Handler: Manipulate state Handler --> View: State changed
External dependencies
If your bundle depends on external library code, the libary must be imported to be included into the build as usual. You can use NPM modules with npm install --save
and import as usual. But before adding dependencies, check that the library isn't already imported through oskari-frontend
like OpenLayers, React, etc. to avoid duplication of library code and/or having multiple versions of the same library.
4.3 Bundle API
Bundles can interact with each other through the API that bundles themselves provide. These are separated into three categories:
request
for requesting something to be done (serializable to JSON)event
for notifying that something happened (serializable to JSON)service
when direct function calls are required between bundles
The Oskari sandbox is the message bus for all of these categories:
sequenceDiagram participant mapmodule as mapmodule
bundle participant layerlist as layerlist
bundle mapmodule->>Sandbox: Add handler for AddMapLayerRequest layerlist->>Sandbox: Add listener for AfterMapLayerAddEvent Note over layerlist: User adds a layer to map using
UI provided by layerlist layerlist->>layerlist: Click toggle layerlist->>Sandbox: Send AddMapLayerRequest Sandbox->>mapmodule: handle AddMapLayerRequest mapmodule->>mapmodule: Download layer metadata
and add to map mapmodule->>Sandbox: Trigger AfterMapLayerAddEvent Sandbox->>layerlist: call listener for AfterMapLayerAddEvent layerlist->>layerlist: update state/UI to show layer on map
4.3.1 Bundle requests
Requests can only have one handler/bundle that handles them. Other bundles request
something to be done by the bundle that offers a request API.
A comprehensive versioned documentation of all available bundle requests can be found here
Providing request API
A bundle can define a request that it wants to provide as its API with a javascript file like this:
Oskari.clazz.define('Oskari.sample.HelloRequest',
function (target = 'World') {
this.target = target;
}, {
__name: 'HelloRequest',
getName: function () {
return this.__name;
},
getTarget: function () {
return this.target;
}
}, {
protocol: ['Oskari.mapframework.request.Request']
});
Where:
name
is the request name and references to the request are done using the name- the
constructor parameters
are values that the handling code needs to do what the request is supposed to do. They can be partially or even fully optional. - the
protocol
at the end is required for Oskari to find any of the request files by just using the request name
By convention the request file should be placed in a request
-folder under the bundles implementation folder. The bundle should then also register a handler for the request in the sandbox. If the request handler is complex you should have the code in a separate file in the request-folder, but simple ones can be done in the bundle instance file for example.
Note! The request file needs to be imported as part of the bundle so they are included in the build. Even as they usually are not directly referenced by the importing code:
import './request/HelloRequest';
Registering the handler is done like this when extending the BasicBundleInstance
class:
this.addRequestHandler('{the request name}', (req) => /* use request methods to get parameters and do what was requested */);
You can also use sandbox functions directly:
sandbox.requestHandler('{the request name}', (req) => handlerFn(req));
Remember that you should cleanup the handler in stop()
and you need the request name to do that. So you may want to store which handlers are registered so you can restart after stopping etc. The BasicBundleInstance
class handles this automatically.
Removing a handler is done like this when extending the BasicBundleInstance
class:
this.removeRequestHandler('{the request name}');
You can also use sandbox functions directly:
sandbox.requestHandler('{the request name}', null);
Using the request API
When you need to do something that another bundle already provides an API for, you can use the request API to execute that functionality through the Oskari sandbox:
sandbox.postRequestByName('{the request name}', ['{request contructor params}', '{as an array}']);
If you want to check if anything in the current application is listening to the request you can do this:
if (!sandbox.hasHandler('{the request name}')) {
// nothing is listening to the request I want to use
// do error handling or try again later
} else {
const theRequestingComponent = this;
const requestBuilder = Oskari.requestBuilder('{the request name}');
sandbox.request(theRequestingComponent, requestBuilder('{request contructor params}', '{as values}'));
}
The request handler might be registered later on at application startup or missing completely from the application and you might need to deal with this as error handling. The example also shows another way of sending requests, but you can use the postRequestByName()
instead of requestBuilder as its easier to work with.
4.3.2 Bundle events
Events can have multiple listeners like you would expect. Bundles that offer an event
API expect other bundles to be interested about the things they do and can notify other bundles when that something has happend.
A comprehensive versioned documentation of all available bundle events can be found here
Providing event API
A bundle can define an event that it wants to provide as its API with a javascript file like this:
Oskari.clazz.define('Oskari.sample.HelloEvent',
function (target = 'World') {
this.target = target;
}, {
__name: 'HelloEvent',
getName: function () {
return this.__name;
},
getTarget: function () {
return this.target;
}
}, {
protocol: ['Oskari.mapframework.event.Event']
});
Where:
name
is the event name and references to the event are done using the name- the
constructor parameters
are values that the listening components might want to know about the change. They can be partially or even fully optional. - the
protocol
at the end is required for Oskari to find any of the event files by just using the event name
By convention the event file should be placed in a event
-folder under the bundles implementation folder.
Note! The event file needs to be imported as part of the bundle so they are included in the build. Even as they usually are not directly referenced by the importing code:
import './event/HelloEvent';
Sending an event is done like this:
const eventTemplate = Oskari.eventBuilder('HelloEvent');
sandbox.notifyAll(eventTemplate('{event contructor params}', '{as values}')
Listening to events
Other bundles can listen and react to things happening on the application. When extending the BasicBundleInstance
class listening to events can be done like this:
this.on('HelloEvent', (evt) => {
console.log(`Hello ${evt.getTarget()}`);
});
Note! Adding a event listener for the same event name in BasicBundleInstance
class currently overwrites the previous listener.
On many cases in oskari-frontend the event listening is much more complex and BasicBundleInstance
class tries to hide this complexity, but it's good to know how it works behind the scenes. Listening to events requires a bundle to register itself into the sandbox with:
sandbox.register(this);
The this
in the example is an object with functions init()
and getName()
. In addition for listening events a onEvent(event)
function is required. Usually this is the bundle instance itself. The init()
function is called by the sandboxes register()
function itself.
After registering to sandbox, the object/instance can start listening to events with:
sandbox.registerForEventByName(this, '{event name to listen}');
After this when an event is triggered the objects/instances onEvent()
function is called with the event as parameter.
onEvent (event) {
const handler = this._eventListeners?.listener(event.getName());
if (!handler) {
return;
}
return handler(event);
}
It's common in oskari-frontend
that references to eventListeners are stored in an object like _eventListeners
as instance variable so it's easy to loop through when we need to cleanup any listeners and have a boilerplaty onEvent()
implementation like above.
Remember that you should cleanup the event listeners in stop()
and you need the event name to do that. So you may want to store which events are being listened to so you can restart after stopping etc. The BasicBundleInstance
class handles this automatically.
sandbox.unregisterFromEventByName(this, '{event name to stop listening}');
4.3.3 Bundle services
Most communication between bundles should happen through the request
/event
API, but it's not always possible. When you can't serialize the messages to JSON or just need direct function API you should use services for that functionality for the bundle.
Bundles can expose a service that is registered to the sandbox to let other bundles get a reference to the service and call the service functions. This is the recommended way of coupling bundles when a direct function call is required.
Another way of doing this is getting a reference to a bundle instance through sandbox.findRegisteredModuleInstance(instanceName)
and that is fairly common in oskari-frontend
as well, but a better approach would be to use the services. With services we can expose a reasonable set of functions required for things to work without exposing the whole bundle implementations. This leads to better decoupling and less accidental bugs as the bundle internals can be updated and changed without worrying if other bundles call their functions directly.
Services should have similar API documentation as requests
and events
. Unforturnately they don't at the moment, but services is the one API we could document and try to have backwards-compatible through versions and/or document any changes to the API changelog. Doing the same for any internals of all the bundles is basically impossible as it would grind every update to a halt.
So whenever you would want to call a function in a bundle directly, you should seriously consider making a pull request instead to add/modify a service to expose that function instead. Otherwise direct function calls (or even worse, referencing an internal variable from another bundle) are very fragile and fiddly to work with in regards of maintaining an application. It probably works now, but is very easily broken in a version update and the bug could be hard to find at that time.
Providing a service API
Providing a service through sandbox for other bundles to use is a simple registration of the service to sandbox like this:
const myService = {
functionForOtherBundle: () => /* do stuff */,
getQName: () => '{service name}'
};
sandbox.registerService(myService);
Usually the service is some kind of class instance instead of a simple object, but the only real requirement that it has to implement a getQName()
function that returns a string as an identifier for the service.
As with other things, bundles should clean up on stop()
and that can be done with:
sandbox.unregisterService('{service name}');
Using service API
Using a service provided by a bundle is done by fetching a reference to the service through sandbox and calling functions provided by the service:
const serviceRef = sandbox.getService('{service name}');
serviceRef.functionForOtherBundle();
4.4 Remote procedure call (RPC)
A demo app with a bunch of examples on how to use Oskari over RPC api is here