Communication between components
If you are new to the C3 AI UI Stack, you can first:
Components and actions
Components follow the Redux principles and communicate with other components through Redux actions, data that represent an operation that components can ingest and act accordingly.
Each UiSdlComponent type defines what actions it can dispatch or consume through special methods called Action Creators. These methods take at least one parameter, the component ID and return an object representing a Redux Action.
For example the UiSdlButton component provides two action creators:
- UiSdlButton#clickButtonAction - Dispatched whenever the button is clicked on. Other components can listen to it and react to it by dispatching other actions.
- UiSdlButton#setDisabledAction - When dispatched, the button will change its look-and-feel as well as interactability depending on its parameters
Action Creator methods are annotated with Ann#UiSdlActionCreator and specify the type of their Redux Action so you can identify them through the type's documentation.
Anatomy of an action
Actions are simple objects that provide data for components to perform operations on their state or trigger other actions, their data looks like the following snippet:
{
"type": "COMPONENT_ID.ACTION_TYPE", // The full type of the action
"payload": {
// The action payload, data used by the component to change its state or communicate changes
"componentId": "COMPONENT_ID", // ID of the action's component
// Other data
},
}Notice the type name follows this naming convention: COMPONENT_ID.ACTION_NAME. The component ID comes from the metadata file name defining the component and the action name is declared in the Action Creator method's annotation.
Listening to and dispatching actions
In order to communicate between components, you'll need to create the necessary wiring to listen to actions and dispatch other actions as needed, we will go through three different approaches that can be taken depending on how complex the use cases are and what kind of logic is needed to determine what actions are going to be dispatched.
Effect triggers will be used in all three options, but they also provide a simple declarative way to dispatch actions.
Effect triggers
Specified in component metadata through UiSdlConnected#effectTriggers, they provide the basis for listening and dispatching actions.
Each object in the effectTriggers collection declares which actions will be listened to and what to do with them, in this simple case we will provide the full action data objects that we want to dispatch.
For example:
{
"type": "UiSdlConnected<UiSdlButton>",
"component": {
"content": "Create"
},
"effectTriggers": [
{
"trigger": "BUTTON_CLICK",
"actions": [
{
"type": "MyApp.MyModal.MODAL_OPEN",
"payload": {
"componentId": "MyApplication.MyModal"
}
}
]
},
]
}Field Descriptions:
trigger: The action or actions to which to listen (BUTTON_CLICK).actions: The actions that are dispatched in response (MODAL_OPEN).payload: Value attached to the outgoing action. In the previous example, it is the modal's component ID.
Note that, as in our example, if the action is for the same component we are defining, we can omit the component ID and just indicate the action name ("BUTTON_CLICK" instead of "MyApp.MyButton.BUTTON_CLICK").
Effect trigger actions limitations
Since we have to provide all the metadata for the dispatched actions in a JSON file, some problems arise:
- We need to know the action's type name. We can find it in the annotated action creator method.
- We need to know the full data shape of the action's payload. We can find this out by force triggering the action and using Redux Dev Tools in the browser but it is not straightforward
- The actions we want to dispatch are static, we cannot provide payloads that depend on other state or that need to be calculated on the fly.
- If multiple effects are listening to the same trigger, their order of execution is not guaranteed.
Simple Epics
If any of the effect trigger actions limitations affect your use case, you can opt to using an Epic type where you provide logic to map actions (listened to) to other actions (to be dispatched).
For example, lets say we have a UiSdlApplicationState called MyApp.PageState that keeps track of a selected record and we want to use the UiSdlModal#updateModalButtonTextAction action to change the modal's button text once it is opened so it shows the selected record name in our application state.
We can define a UiSdlSimpleEpic by creating a type called MyAppUpdateModalButtonEpic and implementing the simpleEpic function:
Note: UiSdlSimpleEpic types have to contain "Epic" in their type name
type MyAppUpdateModalButtonText mixes UiSdlSimpleEpic {
simpleEpic: ~ ts-client
}import { updateModalButtonTextAction } from '@c3/ui/UiSdlModal';
import { getConfigFromApplicationState } from '@c3/ui/UiSdlApplicationState';
export function simpleEpic(action, state) {
const selectedName = getConfigFromApplicationState('MyApp.PageState', state, ['selectedRecord', 'name']);
const newButtonText = `Assign to ${selectedName}`;
return [updateModalButtonTextAction(action.payload.componentId, 'PRIMARY', newButtonText)];
}Extending our component definition, we can enable our simple epic by adding a new trigger in the following way:
{
"type": "UiSdlConnected<UiSdlButton>",
"component": {
"content": "Create"
},
"effectTriggers": [
{
"trigger": "BUTTON_CLICK",
"actions": [
{
"type": "MyApp.MyModal.MODAL_OPEN",
"payload": {
"componentId": "MyApplication.MyModal"
}
}
]
},
{
"trigger": "MyApp.MyModal.MODAL_OPEN",
"effectType": "MyAppUpdateModalButtonEpic"
}
]
}Simple Epic limitations
Note that we overcame some of the effect trigger action limitations by directly calling Action Creator methods in our simple epic code and we could dynamically change the action payloads by passing different parameters to the Action Creator methods when our simpleEpic function is called, but there are some cases that still cannot not be captured with this functionality, like:
- You cannot save context between actions. Simple Epics can only map a single action to a collection of actions, keeping track of past actions, counting them, or operating on more than one action at a time is not supported
- Only synchronous operations are allowed. Simple Epics have to return a collection of actions synchronosly.
- You can only see one action at a time and cannot correlate multiple actions before returning a result. Throttling, debouncing or orchestrating actions is not supported.
Epics
When you need full control of the actions flow or need asynchronous operations, implementing your own UiSdlEpic can provide useful and powerful features to your application.
The main difference between UiSdlEpic and UiSdlSimpleEpic consists in that simple epics are synchronous and take single actions as input whereas full epics are asynchronous and take action streams as input.
Epics are implemented using RxJs operators to manipulate a stream of actions (Observable) and return another stream of actions. If your are not familiar with Epics and RxJs Observables, please make sure you read their documentation.
Following with our example, lets say our modal has a form and we want to validate the value of a field using server side C3 AI APIs.
This is a great use for UiSdlEpic because of the following:
- Input changes can happen very rapidly. We will need to omit some actions since we don't want to send a validation request on each character change and knowing when to omit requires context across actions.
- We need to send an asynchronous HTTP request.
We define an Epic type MyAppValidateInputEpic:
type MyAppValidateInputEpic mixes UiSdlEpic {
epic: ~ ts-client
}import { filter, debounceTime, mergeMap } from 'rxjs/operators';
import { ajax } from '@c3/ui/UiSdlDataRedux';
import { updateFieldValidityAction, updateFormStatusAction } from '@c3/ui/UiSdlForm';
import { UiSdlActionsObservable, UiSdlStatesObservable, UiSdlReduxAction } from '@c3/types';
export function epic(actionStream: UiSdlActionsObservable, stateStream: UiSdlStatesObservable): UiSdlActionsObservable {
return actionStream.pipe(
// Filter for actions related to the input in the form we care about
filter(function (action: UiSdlReduxAction) {
action.payload.field === 'email';
}),
// Wait 100 milliseconds without new actions before continuing
debounceTime(100),
// Call MyValidator.validateEmail
mergeMap(function (action: UiSdlReduxAction) {
return ajax('MyValidator', 'validateEmail', { email: action.payload.value }).pipe(
map(function (xhr) {
const isValid = xhr.response;
return updateFieldValidityAction(action.payload.componentId, action.payload.field, isValid);
}),
catchError(function (error) {
return of(
updateFormStatusAction(action.payload.componentId, {
title: 'Could not validate email',
subtitle: 'An error prevented validating your email',
status: 'ERROR',
}),
);
}),
);
}),
);
}Since this is functionality on the Modal's form, it is better to add the effect trigger there, this way if the form in reused somewhere else or refactored outside the modal, you don't need to worry about losing the validation feature:
{
"type": "UiSdlConnected<UiSdlForm>",
"component": {
"hideFooterButtons": true,
"dataSpec": {
"fieldSets": {
"type": "[UiSdlFormFieldSet]",
"value": [
{
"type": "UiSdlFormFieldSet",
"fields": [
{
"inputElement": {
"type": "UiSdlTextInput"
},
"fieldName": "email",
"label": "Email"
}
]
}
]
}
}
},
"effectTriggers": [
{
"trigger": "INPUT_EXTERNAL_VALIDITY",
"effectType": "MyAppValidateInputEpic"
}
]
}Generic epic implementation templates
UiSdlSimpleEpic
export const simpleEpic = (action, state) => {
// Compute the actions to trigger and return them in a collection
return [];
};UiSdlEpic
The following codeblock contains the bare minimum code required in an epic implementation.
import { mergeMap } from 'rxjs/operators';
import { UiSdlActionsObservable, UiSdlStatesObservable, UiSdlReduxAction } from '@c3/types';
export const epic = (
actionStream: UiSdlActionsObservable,
_stateStream: UiSdlStatesObservable,
): UiSdlActionsObservable => {
return actionStream.pipe(
mergeMap((action) => {
// return actions here
}),
);
};Important Types and Methods mentioned: