Title | Added |
---|---|
App extensions |
3.0.0 |
ADF lets you simplify the app developer's task by providing an extensible app as a starting point.
An extensible app is designed with extension points, which are placeholders where components and other content can be "plugged in" to provide functionality. The app may be supplied with default content for the extension points but the idea is that a developer can easily replace this with custom content as necessary. An organization might find this useful, for example, if they want to create a family of apps with consistent appearance and behavior. One developer can produce an extensible app that can then be adapted by other developers to create the various apps in the family.
A pluggable extension is implemented by a class or data object that provides its functionality. The class or object is then registered in the app with a key/ID string that is used to reference it. The general idea is that only the ID string is used directly in the main app code to designate the extension point, while the actual implementation is loaded and registered separately. In this respect, extension points work somewhat like translation keys - the key is used to mark a place in the app where the actual content will be supplied dynamically.
ADF provides a number of features that offer extension points or help with extensibility in general:
- Components: The Dynamic component
has no content of its own but it has an
id
property that references a registered component extension ID. The referenced component will be added as a child of the Dynamic component at runtime. - Routes: These are registered as key/ID strings that resolve to standard Angular routes. This feature can be used, say, that a click on a list item should send the user somewhere but leave the actual destination up to the developer.
- Auth guards: Routes can be protected by auth guards to prevent unauthorized users from accessing pages they shouldn't see.
- Rules: These are tests that produce a boolean result depending on the app state.
The extensible app can use them with features or
ngIf
directives, for example, to show or hide content in certain conditions. The exact conditions, however, are chosen by the developer who extends the app. - Actions: The extensible app can define a set of application actions that perform basic operations in the app. These are each referenced by a unique key string and can take a data value as a parameter. Items from this set can then be referenced by extension actions. These contain their own key/ID string along with the name of an application action to trigger and a "payload" value to pass as a parameter. The payload can either be a string (to represent a static message, say) or an expression that calculates a result from app state. The expression could, for example, return the current user's name, the currently selected list item or a string composed from several data items.
- Features: What counts as a "feature" varies according to the application but it is intended to mean any salient piece of functionality that can be customized by extensions. For example, a toolbar, navigation bar, login page or tools menu might all be regarded as features. Any of these features could be extended in a variety of ways. A menu, say, might support custom commands that are implemented by actions with each command enabled or disabled depending on the value returned by a rule.
You can register component classes for use with the Dynamic component
using the setComponents
method of the
Extension service (see the Dynamic component page for further details
and code samples). The service also has setAuthGuards
and
setEvaluators
methods that behave analogously.
The recommended way to provide the set of application actions (ie, the built-in actions that can be referenced by extension actions) is to use the scheme defined by @ngrx/store. Briefly, the idea is that all app state is stored centrally and can only be updated by functions triggered by named command strings (eg, "ADD_USER", "CLEAR_SELECTION", "NEW_DOCUMENT", etc). ADF's extensibility features are designed to fit in neatly with @ngrx/store but it has many other advantages, as described on the website.
The set of basic classes, evaluators and actions provided by the app can be used to set up extensions. The easiest way to configure the extension functionality is with an extension config file. The structure of this file (in JSON format) follows the basic pattern shown below:
{
"$id": "unique.id",
"$name": "extension.name",
"$version": "1.0.0",
"$vendor": "author.name",
"$license": "license",
"$runtime": "1.5.0",
"$description": "some description",
"routes": [ ... ],
"actions": [ ... ],
"rules": [ ... ],
"features": { ... }
}
You can use the load
method of the Extension service to read the file into a
convenient object that implements the ExtensionConfig
and ExtensionRef
interfaces.
Note that the extension.schema.json
file contains a JSON schema
that allows for format checking and also text completion in some editors.
By default, the data from the extensions gets merged with the existing one.
For example:
Application Data
{
"languages": [
{ "key": "en", "title": "English" },
{ "key": "it", "title": "Italian" }
]
}
Extension Data
{
"languages": [
{ "key": "fr", "title": "French" },
]
}
Expected Result
At runtime, the application is going to display three languages
{
"languages": [
{ "key": "en", "title": "English" },
{ "key": "it", "title": "Italian" },
{ "key": "fr", "title": "French" },
]
}
You can replace the value by using the special key syntax:
{
"<name>.$replace": "<value>"
}
Example:
{
"languages.$replace": [
{ "key": "fr", "title": "French" }
]
}
Expected Result
At runtime, the application is going to display languages provided by the extension (given that no other extension file replaces the values, otherwise it is going to be a "last wins" scenario)
{
"languages": [
{ key: "fr", "title": "French" }
]
}
The routes
array in the config contains objects like those shown in the
following example:
"routes": [
{
"id": "plugin1.routes.customTrash",
"path": "ext/customtrash",
"component": "yourCustomTrash.component.id",
"layout": "app.layout.main",
"auth": ["app.auth"],
"data": {
"title": "Custom Trashcan"
}
},
...
]
You can access routes from the config using the getRouteById
method of the
Extension service, which returns a RouteRef
object. Note that the references
to the component and auth guards are extension IDs,
as described above.
The actions
array has the following structure:
"actions": [
{
"id": "plugin1.actions.settings",
"type": "NAVIGATE_URL",
"payload": "/settings"
},
{
"id": "plugin1.actions.info",
"type": "SNACKBAR_INFO",
"payload": "I'm a nice little popup raised by extension."
},
{
"id": "plugin1.actions.node-name",
"type": "SNACKBAR_INFO",
"payload": "$('Action for ' + context.selection.first.entry.name)"
},
...
]
The Extension service defines a getActionById
method that returns an
ActionRef
object corresponding to an item from this array.
The type
property refers to an action type that must be provided by the
app (eg, the "SNACKBAR_INFO" in the example presumably just shows a standard snackbar
message).
By default, the payload
is just an ordinary string that can be used for
a message, URL or other static text data. However, you can also define a
JavaScript expression here by surrounding it with $( ... )
. The expression
has access to an object named context
which typically contains information
about the app state. You can supply the object that contains this data via the
runExpression
method of the Extension service, which actually evaluates the
expression. Note that the result of the expression doesn't necessarily
have to be a string.
The simplest type of rule is configured as shown below:
"rules": [
{
"id": "app.trashcan",
"type": "app.navigation.isTrashcan"
},
...
]
The type
is the ID of a RuleEvaluator
function that has been registered using
the setEvaluators
method of the Extension service.
The evaluator is a boolean function that represents whether a certain
condition is true or false (eg, whether an item is selected, whether the user
has certain options enabled, etc). The evaluator has access to a context object
that is supplied from the app during the call to evaluateRule
(defined in
the Extension service).
A more complex rule can take other rules as parameters:
"rules": [
{
"id": "app.toolbar.favorite.canAdd",
"type": "core.every",
"parameters": [
{ "type": "rule", "value": "app.selection.canAddFavorite" },
{ "type": "rule", "value": "app.navigation.isNotRecentFiles" },
{ "type": "rule", "value": "app.navigation.isNotSharedFiles" },
{ "type": "rule", "value": "app.navigation.isNotSearchResults" }
]
}
]
This is mainly useful for creating "metarules" that require certain relationships to hold among the parameter rules. A few useful metarules are defined in the core.evaluators.ts file:
every
: Returns true only if all the parameter rules return truesome
: Returns true if one or more of the parameter rules return truenot
: Returns true only if all the parameter rules return false
Note that parameter rules can also recursively invoke their own rules, etc.
The features
object does not have any defined structure but the intention
is that each key in the object corresponds to the name of a salient feature
of the app that can be extended. The object or array that matches the key
name contains parameters that modify the behavior of the feature, possibly
using actions, rules, etc, defined elsewhere in the config. Suppose, for
example, the app has a tools menu that can be extended with extra commands.
The properties for a new command might include:
- The title shown in the menu
- An icon shown next to the title
- The action that is activated when the command is selected
- A rule that determines whether or not the command is enabled
A features
object to add an extra item to this menu might look like
the following:
"features": {
"toolmenu": [
{
"id": "app.toolmenu.givebiscuit",
"title": "Give a biscuit to the selected user",
"icon": "icons/GiveBiscuit.svg",
"actions": {
"click": "GIVE_BISCUIT"
},
"rules": {
"visible": "app.biscuits.notempty"
}
}
]
}