© Index Data, 2016-2020.
- Introduction
- The Connection Manifest
- Connecting the component
- Using the connected component
- Appendices: for developers
Stripes Connect is one of the most important parts of the Stripes toolkit for building FOLIO UIs. It provides the connection between the UI and the underlying services -- most usually, Okapi (the FOLIO middleware), though other RESTful web services are also supported.
A Stripes UI is composed of React components. (You will need to learn at least the basics of React in order to use Stripes.) Any component may use the services of Stripes Connect to automate communication with back-end services. A component that does this is known as a "connected component".
In order to take advantage of Stripes Connect, a component must do two
things: declare a manifest, which describes what data elements it
wants to manage and how to link them to services; and call the
connect()
method on itself.
This document describes an API that is still in motion. The present version of the code implements something similar to this, but not identical. Further changes are likely.
The manifest is provided as a static member of the component class. It is a JavaScript object in which the keys are the names of resources to be managed, and the corresponding values are objects containing configuration that specifies how to deal with them:
static manifest = {
'bibs': { /* ... */ },
'items': { /* ... */ },
'patrons': { /* ... */ }
};
Each resource is a piece of data -- perhaps a single string, perhaps a
set of structured records. The values of all resources are available
to components as the resources
property -- in this case,
this.props.resources.bibs
etc.
Each resource's configuration has several keys. The most important of these is type
,
which determines how the associated data is treated. Currently, three
types are supported:
local
: a local resource (client-side only), which is not persisted by means of a service.okapi
: a resource that is persisted by means of a FOLIO service mediated by Okapi.rest
: a resource persisted by some RESTful service other than Okapi.
(In fact, the okapi
type is merely a special case of rest
, in
which defaults are provided to tailor the RESTful dialogues in
accordance with Okapi's conventions.)
A local resource needs no configuration items -- not even an explicit
type
, since the default type is local
. So its configuration can
simply be specified as an empty object:
static manifest = {
'someLocalResource': {}
}
REST resources are configured by the following additional keys in
addition to 'type':'rest'
:
-
root
: the base URL of the service that persists the data. -
path
: the path for this resource below the specified root. The path consists of one or more/
-separated components. See the Path Interpretation section below for details on how this is handled. -
params
: A JavaScript object containing named parameters to be supplied as part of the URL. These are joined with&
and appended to the path with a?
. The root, path and params together make up the URL that is addressed to maintain the resource. -
limitParam
: the name of the parameter controlling the number of results per request. -
offsetParam
: the name of the parameter controlling the number of results to skip. -
headers
: A JavaScript object containing HTTP headers: the keys are the header names and the values are their content. -
records
: The name of the field in the returned JSON that contains the records. Typically the JSON response from a web service is not itself an array of records, but an object containing metadata about the result (result-count, etc.) and a sub-array that contains the actual records. Therecords
item specifies the name of that sub-array within the top-level response object. -
recordsRequired
: The maximum number of records to fetch. If further records are available, multiple requests will be made until this count is satisfied via limitParam/offsetParam. -
perRequest
: How many records to fetch per request (via limitParam). -
pk
: The name of the key in the returned records that contains the primary key. (Defaults toid
for both REST and Okapi resources.) -
clientGeneratePk
: a boolean indicating whether the client must generate a "sufficiently unique" primary key for newly created records, or must accept one that is supplied by the service in response to a create request. Default:true
. -
fetch
: a component that adds a new record to an end-point would usually not need to pre-fetch from that resource. To avoid that, it can set this to false. If set to a function, it will be passed the current props of the connected component and the return value used to determine if the resource should be fetched. Default:true
. -
accumulate
: A boolean indicating whether to return a GET value on the resource, which allows it to be used in code that expects to receive a promise. Default:false
. -
abortable
: A boolean indicating whether given resource can be aborted manually by callingresource.cancel()
. Defaultfalse
. -
abortOnUnmount
: A boolean which can be used to control if the given pending resource should be aborted during component unmount. Defaultfalse
. -
permissionsRequired
: A string (or an array of strings) indicating the list of permissions required for the given resource to be fetched. -
shouldRefresh
: An optional function which can be used to indicate if the given resource should be refreshed when another resource is mutated. The function is passed theresource
itself and the refreshaction
. Theaction
contains the standardtype
,meta
, etc fields. Theaction.meta
additionally contains anoriginatingActionType
string that contains the action type that resulted in this refresh request. Eg,@@stripes-connect/DELETE_SUCCESS
. -
resultOffset
: A number, interpolated string, or function indicating what offset into the results list should be fetched. This is an optional workflow that allows fetching of just the next page rather than re-requesting all pages. Note that this workflow is not supported for infinite-scroll due to the risk of out-of-order pages. For example, aMultiColumnList
tied to a resource usingresultOffset
should have itspagingType
prop set toclick
rather thanscroll
.
In addition to these principal pieces of configuration, which apply to
all operations on the resource, these values can be overridden for
specific HTTP operations: the entries GET
, POST
, PUT
, DELETE
and PATCH
, if supplied, are objects containing configuration (using
the same keys as described above) that apply only when the specified
operation is used.
Similarly, the same keys provided in staticFallback
will be used when
dynamic portions of the config are not satisfied by the current state
-- see below.
Okapi resources are REST resources, but with defaults set to make
connecting to Okapi convenient. In particular, default headers are set
appropriately for the GET
, POST
, PUT
and DELETE
operations.
(Also, special-case code that understands Okapi-specific state and
configuration ensures that the correct tenant-ID is sent with each
request, and that the root
is defaulted to a globally-configured
address pointing to an Okapi instance.)
Okapi resources support extra configuration options:
tenant
: A string that specifies tenant value or source of tenant value (props, query etc). This config is optional, if not specified default okapi tenant value is used.
This manifest (from the Okapi Console component that displays the
health of running modules) defines two Okapi resources, health
and
modules
, providing paths for both of them that are interpreted
relative to the default root. In the modules response, the primary key
is the default, id
; but in the health response, it is srvcId
, and
the manifest must specify this.
static manifest = Object.freeze({
health: {
type: 'okapi',
pk: 'srvcId',
path: '_/discovery/health'
},
modules: {
type: 'okapi',
path: '_/proxy/modules'
}
});
(It is conventional to freeze manifests -- making them immutable -- to document and enforce the fact that they do not change once created. See Thinking in Stripes.)
Since we will be talking a lot about URLs in this section, to avoid confusion we must introduce little bit of terminology. We use UI URL to refer to the URL of the user interface, which the human user can see in the URL bar of the browser -- for example,
http://ui.folio.org:3000/users?query=price&sort=Name&filterActive=true
And we use back-end URL to refer to the URLs of resources provided by back-end services such as Okapi, which the UI itself invokes, and which are not visible to the human user -- for example,
http://okapi.folio.org:9130/users?query=title=(username="price*" or personal.first_name="price*" or personal.last_name="price*") and active=true sortby personal.last_name personal.first_name
The purpose of the path
in a manifest resource (in conjunction with
the root
) is to specify how the back-end URL is generated.
The strings provided as path
s in manifests can sometimes be simple
constants, such as _/proxy/modules
or item-storage/items
.
(Such constant paths are often used as part of a staticFallback
.)
However, more often the precise path varies with aspects of the state, such as components of the UI URL's path or query, or the values of local resources. This state-dependent path construction can be expressed in two ways: most often by substituting values directly into a template string; and when the requirements are complex, by calling a function to construct the string from the state.
The four different kinds of state can be substituted into path strings using four different but related syntaxes:
-
:{name}
-- interpolates the value of the named path-component from the UI URL, as extracted by React Router. For example, if the React Router path is/view/:userid
then the path for accessing the back-end web-service can be expressed asusers/:{userid}
Then when the UI is being accessed as (for example)http://ui.folio.org:3000/users/view/45
, the path will be resolved asusers/45
. -
?{name}
-- interpolates the value of the named query parameter from the UI URL. For example, if the path is expressed asitem-storage?query=?{q}
and the UI is accessed athttp://ui.folio.org:3000/items?q=water
, the path will be resolved asitem-storage?query=water
. -
%{name}
-- interpolates the value of the named local resource (see above on local resources). In general, the approach here is to store state in a local resource rather than in React-component state. Given a local resourcesortOrder
, this can be done using something along the lines ofthis.props.mutator.sortOrder.replace('title')
, most likely from an event hander. The state can then be used in a path such asitem-storage?query=?{q} sortby %{sortOrder}
. -
${name}
-- recognised as a synonym of%{name}
to ease transition from this older syntax, but this is deprecated and should not be used in new code. -
!{name}
-- interpolates the value of the named property from the present React component. For example, if the path isperms/users/!{user.username}/permissions
and the component has auser
prop which is an object containing ausername
field with valuefred
, the path will be resolved asperms/users/fred/permissions
.
In general, all the nominated pieces of state -- UI URL
path-components, UI URL query parameters, local state and props -- must be
present in order for these textual substitutions to be performed. If
something is missing -- for example, when the path
item-storage?query=?{q}
is evaluated in a context where the UI URL
does not have a query parameter q
-- then substitution fails, and
the path from the staticFallback
part of the configuration is used
if present. If not, no action is taken until the necessary information
is available.
However, extended syntax, modelled on that of the BASH shell, may be used with any of the three kinds of substitution to provide a fallback value, used when the state is missing:
-
:{name:-val}
yields the value of the named UI URL path-component if any, or the constantval
if it is undefined. -
?{name:-val}
yields the value of the named UI URL query parameter if any, or the constantval
if it is absent. -
%{name:-val}
yields the value of the named local resource if any, or the constantval
if it is undefined. -
!{name:-val}
yields the value of the named component property if any, or the constantval
if it is undefined.
This syntax is useful for providing a default search-term, default sort-order, etc.
In addition, further BASH-like syntax allows a value to be provided
only if the names path-component, query parameter or local resource
does exist: %{name:+val}
yields either the constant val
or an
empty string, according as %{name}
is or is not defined.
Putting these facilities together, the following path
could be
defined for the items
resource in a UI module for inventory
management:
item-storage/items?query=(author=?{q:-}* or title=?{q:-}*) ?{sort:+sortby} ?{sort:-}
This consults two query parameters of the UI URL, each of them
twice. The q
parameter contains a search term and sort
the name of
the CQL field to sort the results by.
?{q}
appears twice because the query
parameter of the back-end URL
contains a CQL query that searches for the term in both the author
and title
fields. In both cases, an empty fallback value is
specified (?{q:-}
): this works because the query is followed by a
wildcard character (*
) in both cases, so that when the query itself
is empty the whole search-term becomes *
.
?{sort}
appears twice: once to generate the sortby
keyword that
introduces the optional sorting clause in CQL, and once to interpolate
the sort criterion itself. When no sort criterion is specified, the
sortby
keyword is not included at all, since it is generated as a
fallback value that is active only when the sort
query parameter is
present ({sort:+sortby}
). Similarly, the value itself falls back to
an empty string, so that there is no sorting clause at all in the
generated path when no sorting parameter is provided in the UI URL.
When the power and flexibility of text substitution and fallbacks are not
sufficient for expressing how to build the back-end URL, arbitrary
JavaScript can be used instead. If the value of a resource's path
,
or one of its params
or headers
is a function rather than a string, then
that function is invoked whenever a path is needed. It is passed five
parameters (though most functions will not use them all):
-
An object containing the UI URL's query parameters (as accessed by
?{name}
). -
An object containing the UI URL's path components (as accessed by
:{name}
). -
An object containing the component's resources' data (as accessed by
%{name}
). -
The logger object in use by stripes-connect.
-
The entire set of props of the component using stripes-connect (which of course contains redundant copies of much of the rest of the information passed in).
The function must return a string to use as the path, or null
if it is unable to do this because a required piece of state is
missing. In the latter case, the path from staticFallback
will be
used if it is defined.
So the function would usually be defined along these lines:
static manifest = Object.freeze({
users: {
type: 'okapi',
path: (queryParams, pathComponents, resourceData) => {
if (queryParams.x) return `users/%{queryParams.x}`;
return undefined;
}
}
});
Similarly, the entire params
and headers
objects can be replaced by a
function that takes the above arguments and returns, instead of a string,
an object to map to the parameters to be sent with requests. Or null if
it lacks necessary information.
React components are classes that extend React.Component
. Instead
of using a React-component class directly -- most often by exporting
it -- use the result of passing it to the connect()
method of
Stripes Connect.
For example, rather than
export class Widget extends React.Component {
// ...
}
or
class Widget extends React.Component {
// ...
}
export Widget;
use
import { connect } from 'stripes-connect';
class Widget extends React.Component {
// ...
}
export connect(Widget, 'stripes-module-name');
(At present, it is necessary to pass as a second argument the name of the Stripes module that contains the connect component. We hope to remove this requirement in future.)
When a parent component is connecting one of its children, it may use the
curried form of connect
, provided on the stripes
prop, which implicitly
passes the module name to connect:
constructor(props) {
super();
this.connectedWidget = props.stripes.connect(Widget);
}
Because the resource object is global to the module, if the same component
will be used repeatedly to retrieve a different value for each item on a list,
e.g. when connecting <LoanDetails>
repeatedly to retrieve the details of
multiple loans, it is necessary to provide the dataKey
option with a unique
value for each connected instance:
constructor(props) {
super();
this.connectedLoans = this.props.IDs.map(id => props.stripes.connect(LoanDetails, { dataKey: id }));
}
render() {
return (
<div>
{this.connectedLoans.map(comp => <comp stripes={this.props.stripes} />)}
</div>
);
}
When a connected component is invoked, two properties are passed to the wrapped component:
-
resources
: contains the data associated with the resources in the manifest, as a JavaScript object whose keys are the names of resources. This is null if the data is pending and has not yet been fetched. -
mutator
: a JavaScript object that enables the component to make changes to its resources. See below.
The mutator
is an object whose properties are named after the
resources in the manifest. The corresponding values are themselves
objects -- one per resource.
Each resource's mutator object has keys that are HTTP methods: the
corresponding values are methods that perform the relevant CRUD
operation using HTTP, and update the internal representation of the
state to match. A typical invocation would be something like
this.props.mutator.users.POST(data)
.
The mutator methods optionally take a record as a parameter,
represented as a JavaScript object whose keys are fieldnames and whose
values contain the corresponding data. These records are used in the
obvious way by the POST, PUT and PATCH operations. For DELETE, the
record need only contain the id
field, so that it suffices to call
mutator.tenants.DELETE({ id: 43 })
.
The POST, PUT and DELETE mutators optionally take a second options
parameter.
Currently the only option available is silent
. The silent option can be used
to indicate that the given mutation should not cause refresh on any corresponding
resources. This is particually helpful when running multiple mutations in a batch
mode when only the last mutation should actually cause the refresh to happen.
Example usage: mutator.tenants.DELETE({ id: 43 }, { silent: true })
.
For the GET mutator method, i.e. when passing accumulate: true
in the
manifest, provide an updated params
argument rather than an updated record, e.g.
const query = `query=username=^${username}`;
mutator.users.GET({ params: { query } })
.then(records => { ... });
Local resources provide a mutator object with two functions, update
and
replace
. The replace
mutator will replace the current value of the local
resource with a new one. update
only works on object values and will do a
shallow merge of properties from the new object onto the old one eg. following
the semantics of Object.assign({}, oldValue, newValue)
.
HTTP errors are caught and processed by Stripes Connect, leaving information in its internal state. By default, these errors are then reported in an alert()
via a Redux observer in Stripes Core, to ensure that they are noticed during development. Since errors at this low level are unusual events in production code, the use of an alert-box is often also also suitable in production, so often no explicit error-handling is necessary.
But applications can instead elect to be responsible for their own error-handling. To disable the alert-box for a particular resource, add a throwErrors: false
property to that resource's object in the manifest.
When doing this, there are two ways to catch the errors for handling or reporting.
Mutators return promises which can be interrogated using .then
and .catch
as usual. Consider the following code (from the Course Reserves module): a new reserve is created by a POST to the reserves
resource, which has throwErrors: false
. Whether the operation succeeds or fails, the user is notified via a suitable callout:
this.props.mutator.reserves.POST({ courseListingId, copiedItem: { barcode } })
.then(addedRecord => {
this.showCallout('success', `Added item "${addedRecord.copiedItem.title}"`);
})
.catch(exception => {
exception.text().then(text => {
this.showCallout('error', `Failed to add item ${barcode}: ${text}`);
});
});
Alternatively, application code can inspect the resource' failedMutations
to notice when something has gone wrong. The usual approach is to notice when a new failed mutation has appeared and report that. One way to do this is using the componentDidUpdate
lifecycle method to compare the failedMutations
of the previous and present properties:
componentDidUpdate(prevProps) {
const { failedMutations } = this.props.resources.reserves;
const prev = prevProps.resources.reserves.failedMutations;
if (failedMutations.length > prev.length) {
console.log('componentDidUpdate: new failure mutations:', failedMutations.slice(prev.length));
}
}
Either of these approaches can be used, as best suits the architecture of the specific application.
These sections are only for developers working on Stripes itself. Those working on using Stripes to build a UI can ignore them.
-
All state is stored in a single branching structure, the Redux store. (Module creators should not need to know about details of Redux, and especially not about reducers, but this idea of a single state store is important nevertheless.)
-
Data in this state structure consists of resources, each named by a string.
-
Rather than each module having its own namespace within the structure, all modules' data is kept together in a single big table.
-
To avoid different modules' same-named data from clashing, the code arranges that the keys in this table are composed of the module name and the resource name separated by an underscore: moduleName
_
resourceName- XXX In fact, the code that does this is the
stateKey()
methods of the various resource-types. That means (A) we need to be very careful that new resource-types also remember to do this; and (B) we probably made a mistake, and this should instead by done at a higher level instripes-connect/connect.js
.
- XXX In fact, the code that does this is the
-
A component's resource names are defined by the keys in its data manifest. The value associated with each key is tied to the resource specified by its parameters -- for example, the
root
andpath
of a REST resource. In general, that value is a list of records: some components will deal only with a single record from that list.- XXX For example,
PatronEdit.js
deals only with a single record; but it works with thepatrons
resource, which is a list of records, and picks out the one it wants by usingpatrons.find((p) => { return p._id === patronid })
. If I have understood this correctly, it looks like a grotesque inefficiency that will quickly become unworkable as we start to use large patron databases.
- XXX For example,
-
In general, a Stripes module contains multiple connected components. The data manifest is specific per-component. Components may communicate with each other, or share cached data, by using the same data keys. It is the module author's responsibility to avoid inadvertent duplication of keys between unrelated components.
-
Some components may exist in multiple simultaneous instances: for example, a list-of-records component may be designed such that a user may pop up a more than one full-record component to see the details of several records at once. In this case, the state keys are different because the records' IDs are included (due to the manifest path being of the form
/patrons/:patronid
, containing a placeholder.) -
For local resources, which are not persisted via a REST service such as Okapi, some means must be established whereby each individual datum is individually addressable. Only then can multiple instances of the same component that uses local storage co-exist.
-
For this reason, it may be worthwhile to prioritise the development of a page that has two instances of the Trivial module, and see that they can each maintain their own data.
-
Also to be done: a simple implementation of search preferences, as a model for how two components (SearchForm and SearchPrefernces) can deliberately share data.
-
Right now there is no clear standard as to what data is returned by
Okapi services -- for example, a single record by id
yields a single
record; but a query that matches a single record yields an array with
one element.
We use the Redux Crud library to, among other things, generate actions. It causes a number of compromises such as our needing to clear out the Redux state at times, because it is designed for a slightly different universe where there is more data re-use.
As part of that, it prefers to treat the responses as lists of records
that it can merge into its local store which makes having a top level
of metadata with an array property patrons
or similar a bit
incompatible.
We currently pass it a key from the manifest (records
) if it needs
to dig deeper to find the records. But that means we just discard the
top level metadata. It may soon be time to reimplement Redux Crud
ourselves and take full control of how data is being shuffled
around. Among other things, it would give the option to let our API be
more true to intent and transparently expose the data returned by the
REST request.
Can we get a count of holds on an item? How does that API work and does our above system mesh with it well enough to provide a pleasant developer experience?
This is in the separate document A component example: the PatronEdit component
XXX To be written