Cell Action Plugin

QuickBrick actions plugins are the Quick Brick way to abstract and reuse component logic inside the Zapp apps. Common examples of use case are:

  • Providing specific functionality for buttons inside a cell or the modal bottom sheet
  • Storing and managing custom state(React context)
  • Getting/preparing an async data before app renders it's UI

Motivation

The main motivation for creating an action plugin was to provide a way for Zapp users to easily alter plugins internal behaviour like (button clicks). Having that power gives users the ability to create more reusable plugins. Imagine creating a cell plugin with the button on it and to be able to alter that button behaviour from Zapp, without touching any code.

A good example of implementing the above idea in real life would be a "Favourites button" problem.

Let's start with identifying what most of the favourite buttons have in common and what differs:

Similarities:

  • Selected, unselected and loading state (UI)
  • Checking the state
  • Saving the state

Differences:

  • storage solution(backend, localStorage, session storage, etc.)
  • getting information about favourites state.

Knowing the above we know that we can safely implement button UI in the reusable Cell component but we need to abstract the storing/getting favourites logic to make it a viable option for as many Zapp user as possible.

The next challenge would be to figure out how to connect a UI element (button) to the Zapp plugin providing functionality/action:

Zapp plugins provide a custom configuration which can be used for that purpose settings

In the above example, Zapp user is manually imputing action plugin identifier so we can use it inside our plugin.

As a base API for actions plugins we decide to use React Context API wrapped in the Higher order component. Choosing this solutions gave us number of advantages like:

  • We can freely use React and react-native functionalities;
  • We have access to all QuickBrick custom hooks and bridge methods;
  • We can use Context API to easily store and update state;
  • We can easily provide data and functions to the components;
  • We are not introducing any new concepts for React developers;

Declaring this in your plugin will mount a context at the top level of the app, allowing you to have your state accessible anywhere. This can be used for all types of plugins, even those who don't provide a cell action. Your context will be available anywhere simply by using our useActions hooks.

const Action = React.createContext({});
function ActionWrapper(Component: ReactComponent) {
return WrappedWithActionContext = (props: any) => (
// This will allow to manage the state internally within the plugin
const [favourites, setFavourites] = React.useState<[ZappEntry | ZappFeed]>([]);
const addFavourite = (item: ZappEntry | ZappFeed) => {
setFavourites([...favourites, item]);
};
const removeFavourite = () => {
const itemIndex = favourites.findIndex(fav => equals(fav, item));
const newFavourites = favourites.splice(itemIndex, 1);
setFavourites(newFavourites);
};
const invokeAction = async (item: ZappEntry | ZappFeed) => {
// here we can handle the action for the specific item,
// add it to favourites if it is not there,
// or remove it from favourite if it is present
if (favourites.includes(item)) {
removeFavourite(item);
} else {
addFavourite(item);
}
}
const context = {
state: favourites,
invokeAction,
getInitialState,
};
<Action.Provider value={context}>
<Component>{props.children}</Component>
</Action.Provider>
);
}

As we have plugin ID and our Action plugin with context provider we need an easy way to use it. Custom hooks come to our rescue:

const MyCustomPlugin = (props: { action: { identifier: string } }) => {
const myAction = useActions(props.action.identifier);
const onPress = (item: ZappEntry | ZappFeed) => {
myAction.invokeAction(item);
};
};

The useActions hook will subscribe our component to the action plugins context.

Having all of above in place we can define required methods for the favourites functionality and we can create multiple action plugins using different logic under the hood.

What is an Action plugin?

Action plugins is an npm module that default exports object with 3 properties:

  • actionName <- Name of the action
  • contextProvider <- Higher Order Component, returning React Component, returning children wrapped in context provider.
  • context <- React Context

index.js

import React from "react";
type QuickBrickCellActionContext = {
state?: any;
getInitialState?: () => Promise<void>;
invokeAction: (item: ZappEntry | ZappFeed) => void;
[K: string]: any;
};
const ActionContext = React.createContext<QuickBrickCellActionContext>(null);
function ActionProviderWrapper(Component) {
return function ActionProvider({ children }) {
const context: QuickBrickCellActionContext = { ... }
return (
<ActionContext.Provider value={context}>
<Component>{children}</Component>
</ActionContext.Provider>
);
};
}
export default {
actionName: "Name of your action", // Format isn't important
contextProvider: ActionProviderWrapper,
context: ActionContext,
};

Using react context we can easily create actions with stateful or stateless logic and on top of that we have access to all react functionalities.

Zapp manifest(zappifest)

Action plugin is just a general plugin that should have an action suffix.

{
"api": {},
"dependency_repository_url": [],
"dependency_name": "@applicaster/your-action-name-action",
"author_name": "Applicaster",
"author_email": "zapp@applicaster.com",
"name": "your_action_name_action",
"description": "Your plug description",
"type": "general",
"react_native": true,
"identifier": "your_action_name_action",
"ui_builder_support": true,
"whitelisted_account_ids": [],
"deprecated_since_zapp_sdk": "",
"unsupported_since_zapp_sdk": "",
"general": {},
"targets": ["mobile"],
"ui_frameworks": ["quickbrick"],
"platform": "ios_for_quickbrick",
"dependency_version": "0.1.0",
"manifest_version": "0.1.0",
"min_zapp_sdk": "0.1.0-alpha1"
}

Action with initialState

Sometime you will need your action to pre-load some state to avoid layout flickers (eg. favourite items). To resolve this issue we added an ability to fetch all relevant data on app load.

Warning, using this functionality can increase your app loading time as it will cause an entire app to wait for your async function to resolve before it loads a UI)

To use initialState loader you need to pass a method named getInitialState to your context value. The Quick Brick app will call it on every app launch.

function ActionProviderWrapper(Component) {
return function ActionProvider({ children }) {
const [favourites, setFavourites] = React.useState(null);
async function getInitialState() {
const favList = await yourAsyncMethod();
setFavourites(favList);
return true; // remember to resolve your Promise by returning something.
}
const invokeAction = async (item: ZappEntry | ZappFeed) => {
...
}
const context = {
state: favourites,
getInitialState,
invokeAction
}
return (
<ActionContext.Provider value={context}>
<Component>{children}</Component>
</ActionContext.Provider>
);
};
}

Stateless action

The implementation is exactly the same, you just don't use state.

function ActionProviderWrapper(Component) {
return function ActionProvider({ children }) {
const staticData = {
myFavouritePlaceholder: "foo",
};
const logHi = () => {
console.log("hi");
};
return (
<ActionContext.Provider value={{ invokeAction: logHi, staticData }}>
<Component>{children}</Component>
</ActionContext.Provider>
);
};
}

Advanced Features

Check if the action is available for the current entry

Depending on the content of the cell presenting the action button, the action may not be relevant for that item. If you want to use this feature, you can define a function named isActionAvailable in your context. This function is invoked with the entry in the cell, and should return a boolean. If the function is defined and returns false, the button will not be presented on this entry.

function ActionProviderWrapper(Component) {
return function ActionProvider({ children }) {
const isActionAvailable = (item: ZappEntry) => {
return item?.extension?.some_flag_to_activate_the_feature;
};
const context = {
state,
getInitialState,
invokeAction,
isActionAvailable,
};
return (
<ActionProviderContext.Provider value={context}>
<Component>{children}</Component>
</ActionProviderContext.Provider>
);
};
}

Provide a feed of entries from action state

Action plugins can be used as a data source to provide a feed of items handled by the plugin. For instance, We may want to have a feed of favourited items.

Action plugins can use the available API for this purpose

Advanced button state

In the example above, we've shown a stateless action (i.e. share) and a 2-states action (i.e. favourites).

There are scenarios where an action could need to support more than 2 different states (i.e. downloads). You could also want to provide multiple assets, a label to be presented next to the button (in the Modal Bottom Sheet plugin), and potentially an animated asset.

All this is possible with the Action plugin API, thanks to the second parameter of the invokeAction function you declare. While the first argument is the entry, the second argument is an object which holds an updateState function, which will allow you to define the state of the button, the asset to display (image or React Component), and the label to show if the button is rendered in a place where labels can be displayed.

Here's an example of how it works:

function ActionProviderWrapper(Component) {
return function ActionProvider({ children }) {
const [state, setState] = React.useState(initialState);
async function getInitialState() {
return state;
}
const invokeAction = async (item, { updateState }) => {
// perform the action here based on the content of the entry
startAction(item);
// you can now invoke the updateState function, and assign
// the state, asset, and label required. This will update
// the button
updateState({
state: 1,
label: "Pending",
// assets can be either images or React components
// which allows to provide animated buttons.
// check the type declarations to see the props available
// when providing a component as asset
asset: PendingAnimatedAssetComponent,
});
// you can invoke it multiple times - this allows you
// to provide immediate visual feedback to the users
// then perform an async action, and finally update the
// state based on the response
const result = await doPerformAction(item);
updateState({
state: result.success ? 2 : 0,
label: result.success ? "done" : "error",
asset: result.success
? require("./assets/success.png")
: require("./assets/error.png"),
});
};
// if you chose to use the updateState function, then
// you need to expose in your context a function
// which can provide synchronously the initial state,
// label, and asset to be rendered in the button
const initialEntryState = (item: ZappEntry) => ({
state: 0,
label: "click here to do something",
asset: require("./assets/default-asset.png"),
});
const context = {
state: favourites,
getInitialState,
invokeAction,
initialEntryState,
};
return (
<ActionContext.Provider value={context}>
<Component>{children}</Component>
</ActionContext.Provider>
);
};
}

Allow Buttons to register listeners to your context

Action Plugins are by design driven by user interaction. But some operations can be long, and require to notify of a state update after the button has been re-rendered, or update the state on another part of the UI which is also presenting the same entry with the same action button.

The Action plugin can expose a function in it's context to allow buttons or consumers of the action to register listeners. The Action plugin should then invoke the listeners that are registered when the state of a relevant entry is modified, so the buttons or consumers can re-render and update the UI.

Here's an example of how it works:

function ActionProviderWrapper(Component) {
return function ActionProvider({ children }) {
const listeners = React.useRef([]).current;
const [state, setState] = React.useState(initialState);
async function getInitialState() {
return state;
}
const invokeAction = async (item) => {
/* ... */
};
// if you chose to use the listeners functionality,
// then you will also need to expose in your context
// the initialEntryState function
const initialEntryState = (item: ZappEntry) => ({
state: 0,
label: "click here to do something",
asset: require("./assets/default-asset.png"),
});
// Define a function to register the listeners somewhere.
// In this example we use an array held in a react ref
const addListeners = ({entry, listener}) => {
listeners.push({entryId: entry.id, listenerFn: listener });
// like many React Native Event managers, the addListener
// function should return the removeListener function.
const removeListener () => {
listeners = listener.filter(({entryId, listenerFn}) => {
entryId !== entry.id && listenerFn !== listener
});
}
return removeListener;
}
// now we can invoke the listeners for the relevant entries
// when the state changes
useEffect(() => {
if (stateHasChanged(state)) {
const entryModified = getEntryChanged(state);
listeners.forEach(({entryId, listenerFn}) => {
if (entryId === entryModified.id) {
// the listener needs to invoked with the same
// object used to provide `initialEntryState`, and
// the `updateState` function described above
listenerFn({
state: 3,
label: "label to show for state update",
asset: require("./asset/...")
})
}
})
}
}, [state])
const context = {
state: favourites,
getInitialState,
invokeAction,
initialEntryState,
addListeners
};
return (
<ActionContext.Provider value={context}>
<Component>{children}</Component>
</ActionContext.Provider>
);
};
}

API

useActions(pluginId: string) => contextValue

React Hook to subscribe to the custom action context

Kind: global constant
Returns: * - global action provider context or context for specific action plugin

ParamTypeDescription
pluginIdstringIdentifier of the action plugin

If called without parameters the hook will return list of the action plugins added to the app;

Example:

// Action plugin, index.js
// identifier(from plugin-manifest): 'my-custom-action'
import React from "react";
const ActionContext = React.createContext(null);
function ActionProviderWrapper(Component) {
return function ActionProvider({ children }) {
return (
<ActionContext.Provider value={{ state: "fooBar" }}>
<Component>{children}</Component>
</ActionContext.Provider>
);
};
}
export default {
actionName: "Name of your action",
contextProvider: ActionProviderWrapper,
context: ActionContext,
};
// CustomPlugin, using above action:
const MyCustomPlugin = (props) => {
const myAction = useActions("my-custom-action");
const allActions = useActions();
console.log(myAction);
/**
* { state: "fooBar" }
*/
console.log(allActions);
/**
* {
* actions: {
* "my-custom-action": {
* actionName: "Name of your action",
* contextProvider: ActionProviderWrapper,
* context: ActionContext,
* }
* }
* }
*/
};