@motorcycle/collection

1.1.0 • Public • Published

Component Collections for [Motor]Cycle.js

Build Status Gitter

This is a library to make management of collections of child components much easier, and was inspired by Nick Johnstone's excellent collection library for Cycle.js and xstream. It's based on Most.js streams (as are all Motorcycle packages) and while it shares some similarities with cycle/collection, it was written from scratch as a universal way to manage and combine sets of child components in general, whether they're known about in advance, such as the different sections of a web page, or dynamically changing in length, such as for a live stream of messages.

Note: this library is fully functional, well-tested and perfectly valid for use in new and existing projects. Ongoing development is on hold though, and will be largely superceded by Collectable.js and Salix, which aim to solve this particular use case at a more fundamental level.

Contents

Features

  • Immutable by default; perfect for managing using scan, loop, map and others.
  • Can be used as both as a dictionary or as a list, with methods to generate unique keys if not specified when adding an item to a collection
  • Define an arbitrary number of component types that the list will support
  • Add, update and remove any number of children of any of the component types defined for the collection
  • Collection size grows and shrinks dynamically
  • Manage as a dynamic array of components for cases such as lists, or set children by key to streamline the management of different predefined sections of a page
  • Merge children into unified groups of arbitrary sinks that the parent component can return directly or merge with its own local streams
  • Automatically project a common sink from each child to an array of the most recent values from each child component; ideal for use with map, combine and combineArray
  • Customizable lifecycle operations for each defined component type, including instantiation, equality and sort comparison operations
  • Well-tested with almost 200 unit tests

Future Enhancements

  • Support for slice, skip, take, orderBy and filter to transform the list and efficiently handle rendering, paging and filtering of very large collections and to make it possible to prevent unnecessary processing of offscreen list items
  • Automatic provision of an additional source stream to each child component so that the component has access to item-specific list metadata, such as the list size, the index of the item in the list and so forth

Installation

npm install --save @motorcycle/collection

Reference the library using an import or require statement:

import collection from '@motorcycle/collection'
// or
const collection = require('@motorcycle/collection')

Tutorial and Guide

Motorcycle Collections are a great way not only to manage dynamic lists of a components in an application, but to manage your application's graph of child components in general.

Getting started

All of the examples below assume the same basic setup described in this tutorial, which uses the simple HTML code below. It is up to you to use Webpack, Browserify or some other solution to bundle your code into the referenced main.js script referenced in the HTML header. DOM functions such as div and header are part of the HyperScript Helpers library re-exported as part of the Motorcycle DOM library. The first tutorial below shows standard imports and the basic application setup that we'll use, but, for brevity, these will be omitted in subsequent code samples.

HTML Boilerplate

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Motorcycle Collections Tutorial</title>
  <script src="main.js"></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>

Managing child components

Let's start by creating a very simple application and use a collection just to manage some predefined child components. The component examples below are overly simplistic of course, as we're just demonstrating a concept. In reality, sections of functionality would be broken out into separate components only when there is value to managing them independently.

import * as most from 'most';
import {run} from '@cycle/most-run';
import {div, h1, header, main, nav, p, makeDOMDriver} from '@motorcycle/dom';
import Collection from '@motorcycle/collection';

function Header(sources) {
  const view$ = most.just(
    header('.tutorial-header', [
      h1('Component Collections Tutorial')
    ])
  );
  return {
    DOM: view$
  };
}

function Navigation(sources) {
  const view$ = most.just(
    nav('.tutorial-nav', [
      div('Nav goes here.')
    ])
  );
  return {
    DOM: view$
  };
}

function Content(sources) {
  const view$ = most.just(
    main('.tutorial-content', [
      p('Welcome to the tutorial!')
    ])
  );
  return {
    DOM: view$
  };
}

function App(sources) {
  const header = Header(sources);
  const navigation = Navigation(sources);
  const content = Content(sources);

  // Here we'll use a collection in the simplest way possible; by using it to
  // streamline the management of our child components. Note that even though
  // we're setting items by key, the collection retains items the order in which
  // they were added, so you can be sure they'll always be returned in the order
  // you expect.

  const components = Collection()
    .setInstance('header', header)
    .setInstance('nav', navigation)
    .setInstance('content', content);

  // Now that the collection is managing our child components, we can combine
  // the views easily and use them as part of our app view. The `combineObject`
  // static method combines the latest values from the specified sink in each
  // child component and emits an updated object containing key/value pairs of
  // for latest value emitted by each component.

  const view$ = components.combineObject('DOM')
    .map(({header, nav, content}) => div('.container', [header, nav, content]));

  return {
    DOM: view$
  }
}

run(App, {
  DOM: makeDOMDriver('#app')
});

Alternatively, rather than using combineObject, we could use combineArray if we would prefer the child components to be emitted as an array rather than an object of key/value pairs, like so:

const view$ = components.combineArray('DOM')
  .map(([header, nav, content]) => div('.container', [header, nav, content]));

Replacing a child component when state changes

Sometimes we want to be able to switch out one component for another depending on the state of our application. A simple example would be changing a login form in the sidebar to a profile picture and account menu if the user is logged in. Obviously this isn't too difficult to handle manually, but using a collection can make it a breeze.

This example assumes the existence of an auth$ source stream. The hypothetical stream emits {profile: null} if not logged in, or {profile: {...}} otherwise, and uses @most/hold to retain the last emitted value so that when the stream is observed by a new component, it'll receive the most recent auth state straight away.

function LoginForm(sources) {
  // ...
  return { DOM: view$ };
}

function AccountControls(sources) {
  // ...
  return { DOM: view$ };
}

function App(sources) {
  const components$ = sources.auth$

    // Filter out values where the login state has not changed. Emitting a value
    // past this point causes the component to be recreated and replaced in the
    // collection. Seeing as the component will itself observe the auth$ source,
    // it will take care of reacting to secondary changes to auth information.
    .skipRepeatsWith((a, b) => !a.profile === !b.profile)

    // Use `scan` to initialize a new collection and keep track of the child
    // component that will be swapped out depending on login state.
    .scan((components, auth) => {
      const account = a.profile ? AccountControls(sources) : LoginForm(sources);
      return components.setInstance('account', account);
    }, Collection())

    // Skip the initial blank collection that `scan` will emit first
    .skip(1);

    // At this point we could perform further operations to append and manage
    // more child components in the list as well. Alternatively or additionally,
    // we could pre-initialize the collection with the components that we know
    // won't be changing externally.

    // ...

    // Because we have a stream of collections, rather than access to the
    // collection itself, we can use the static equivalents of the available
    // 'emit' functions:
    const view$ = Collection
      .combineObject('DOM', components$)
      .map(({accountView}) => render(accountView));

    return {
      DOM: view$
    };
}

Combining multiple sinks from child components

Obviously child components will usually return more than just a DOM stream. HTTP requests and WebSocket messages, to name just a couple, are potential sinks that need to be returned to their respective drivers in order for the child component to function correctly. Use of the static Collection.merge method does all the hard work for us.

function App(sources) {
  // Create our child component collection stream
  const components$ = makeChildComponents(sources);

  // Render the app view any time a child component's view changes
  const view$ = Collection.combineObject('DOM', components$)
    .map(render);

  // Merge pass-through sinks from child components into a new sinks object
  const childSinks = Collection.merge(['HTTP', 'socket'], components$);

  // Create a sinks object that is relevant to the local `App` component
  const appSinks = {
    DOM: view$,
    HTTP: emitSomeMoreHTTPRequests(sources) // Illustrative only
  };

  // Finally, merge both sinks object into a new, unified sinks object
  const sinks = Collection.merge(appSinks, childSinks);

  // The final `sinks` object contains: {DOM, HTTP, socket}
  return sinks;
}

Simple merging of a common sink to a derivative stream

As a convenience, the merge method can take all child components that contain the specified sink and merge those sinks into a single derivative stream.

const request$ = Collection.merge('HTTP', components$);

Or use the equivalent instance method:

const request$ = components.merge('HTTP');

Dynamic lists of child components

Often you'll need to manage variably-sized lists of a common component type, or of several potential component types. A collection can take care of the instantiation of list items automatically.

First, register a component type with the collection:

function MyComponent(sources) {
  // ...
}

const list = Collection(MyComponent);

Now we can add, remove and update items in the list. This example is based around managing a dynamic list of people, and assumes the existence of an action$ stream which emits items of {action: 'add|remove', person: {id, sources?}}.

Note that, in most cases, replacing a specific collection item with a new component instance should be an uncommon occurrence, as it's less efficient than simply updating the component via the component's source streams. As such, usually we'll want to pass in a custom sources object containing a prepared props$ stream, and simply emit new props objects when a component's properties change.

function updateList(list, {action, person}) {
  switch(action) {
    case 'add': return list.set(person.id, person.sources);
    case 'remove': return list.remove(person.id);
    default: return list;
  }
}

const people$ = action$
  .scan(updateList, Collection(Person))
  .skipRepeatsWith(Collection.isEqual);

Notice the static Collection.isEqual method we've used above, which checks the underlying immutable data structure for equality so that unnecessary processing can be avoided if nothing has changed. There is also a static Collection.areItemsEqual method if you only want to check for changes in the list of child items and ignore changes to the list of component definitions, which would trigger inequality in Collection.isEqual.

Managing lists of components that don't have a key

Sometimes we don't care to manually identify our child components. Instead we just want to build a list by appending items and otherwise letting things manage themselves. This example revolves around a hypothetical simulation of the Chrome developer console. Each log message potentially displays items of data that can be clicked and expanded for further inspection.

To add an item for which we don't have a key, simply use the add method, which generates a unique key and then appends the item internally using set. Remember, collections use ordered maps internally, which is why we're able to use keys but still treat the collection like a list.

function ConsoleMessage(sources) { /* ... */ }

const console$ = logMessage$
  .scan((messages, msg) => messages.add(msg), Collection(ConsoleMessage));

Handling multiple component types in a single list

Most of the time, all items in a list will be of a uniform component type, but sometimes we'll want the option to create children of differing types, depending on context. Having support for multiple component types in a single list can also be useful for cases where we're managing a collection of known entities, but, for whatever reason, we'd like the collection to manage the lifecycle of those components rather than requiring us to do so ourselves.

To define additional component types beyond the first, we can either define the set of available component types when the collection is created, or we can define them on an adhoc basis.

To define multiple component types when the collection is instantiated, we pass an options object to the collection. The first item in the types list is used by default if no type is specified when adding or setting a new list item.

function ImageItem(sources) { /* ... */ }
function VideoItem(sources) { /* ... */ }
function MessageItem(sources) { /* ... */ }

const emptyList = Collection({
  types: [MessageItem, ImageItem, VideoItem]
});

// Use the name of the function to specify which component type to use:
const videos$ = video$.scan((list, sources) =>
  list.add(VideoItem.name, sources), emptyList);

// Or use the `set` method if you want to specify a key for the item:
const images$ = image$.scan((list, image) =>
  list.set(image.id, ImageItem.name, image.sources));

To define another component type after the list has been instantiated, use the define method:

function WidgetItem(sources) { /* ... */ }
const updatedList = list.define(WidgetItem);

Taking control of a component's lifecycle within a list

The simplest way to define a component type within a list is to provide a component function, as the examples above demonstrate. You then supply a preconstructed sources object which the collection will use when it instantiates the component. Sometimes, however, you need a little more control over how components are instantiated and managed. When defining a component, rather than passing the component function directly, you can provide a component type definition object.

First let's look at how a collection works by default.

When you ask the collection to add a new item, the collection needs to actually instantiate the component first. So this operation:

const newList = list.add(someType, sourcesToUse);

... actually results in the following:

(1) The collection locates the type definition matching the specified type id. A type definition has the following basic signature. If you only provided a component function when defining the collection, the collection creates a definition for you and fills in the blanks:

type ComponentType: {
  sources?: Sources
  key: ComponentTypeIdentifier
  fn?: ComponentFunction
  instantiate?: (input: Input, type: TypeDefinition, list: ICollection) => Sinks
}

All of these can be overridden with custom values as needed.

(2) Once it has located the type definition, it calls the type's instantiate function, passing in the input you provided (which has always been a sources object in the examples up until this point), as well as the type definition and a reference to the collection itself. The default instantiate implementation creates a new sources object by merging the item input (if it appears to be a sources object) into the default sources for the type definition (if present) and then merges that into the collection's default sources object (again, if present). This means you can supply default sources at both the collection level and the component type level and then only worry about supplying the additional source streams that are specific to the object, such as a props$ stream. The default instantiate function then calls the component function (the fn property of the type definition), passing it the new sources object, and then returns the result.

Overriding the instantiate function with your own implementation gives you complete control over how list inputs are handled in the process of instantiating a component. While the default implementation treats inputs as sources, your own implementation can make any assumption it wants, allowing you to provide other types of inputs such as props or just simply pure data.

A custom type definition with your own instantiate function might look like so:

function emitVideoProps(video) {
  return most.just(video);
}

const emptyList = Collection({
  types: [{
    key: 'video',
    instantiate: (video, type, list) => {
      const sources = {
        ...list.sources,
        ...type.sources,
        props$: emitVideoProps(video)
      };
      return VideoComponent(sources);
    }
  }]
});

// And then:
const newList = emptyList.add('video', {title: 'To Kill A Mockingbird', year: 1962});

Notice that the fn property has been omitted, as the custom instantiate function calls the component function directly. The reason you normally need to specify the component function when defining a type is because otherwise the Collection instance won't know how to create your sinks when you pass it a new input, and the default implementation of the instantiate function looks for the fn property.

Naturally you could also do the equivalent of the above example, but via the define function.

API Reference

  • API Specification - a cheat sheet showing all type interfaces and function signatures
  • Types Reference - detailed documentation for the types and functions listed below

API Specification

Implied Types

Note that the Map type referenced further below refers to the native ES2015 Map type. ListOptions, ComponentType, and ComponentInstance are defined further below.

type Key: String
type KeyValueMap<A>: { [key: Key]: A, ... }
type Stream: most.Stream
type Sources: KeyValueMap<any>
type Sinks: KeyValueMap<Stream>
type Component: (Sources) => Sinks
type Identifier: String|Number
type ComponentTypeIdentifier: Identifier
type InstanceIdentifier: Identifier
type ComponentDefinition: Component|ComponentType
type Input: Sources|any

Collection API

// # CONSTRUCTION

// New collection, optionally with a default component type, or specify further options
Collection: () => ICollection
Collection: (fn: ComponentFunction) => ICollection
Collection: (type: ComponentTypeIdentifier, fn: ComponentFunction) => ICollection
Collection: (options: ComponentDefinition) => ICollection
Collection: (options: ListOptions) => ICollection

// # STATIC METHODS FOR MANAGING LIST STREAMS

// Check if a value is an ICollection instance
Collection.isCollection: (arg: any) => Boolean

// Merge the specified sink from each collection item into a single resultant stream
Collection.merge: (key: Key, list$: Stream<ICollection>) => Stream

// Merge each of the specified sinks in each collection item into a single unified sinks object
Collection.merge: ([key: Key, ...], list$: Stream<ICollection>) => Sinks

// Merge the streams from each of two or more sinks objects into a single unified sinks object
Collection.merge: (a: Sinks, b: Sinks, ... n: Sinks) => Sinks

// Similar behaviour to that of most.combineArray, with input streams taken by mapping the specified
// sink key from each component in the collection.
Collection.combineArray: (key: Key, list$: Stream<ICollection>) => Stream<Array<any>>
Collection.combineArray: (keys: Key[], list$: Stream<ICollection>) => Stream<Array<KeyValueMap<any>>>

// As above, but emits a native `Map` instead of a plain object
Collection.combineObject: (key: Key, list$: Stream<ICollection>) => Stream<KeyValueMap<any>>
Collection.combineObject: (keys: Key[], list$: Stream<ICollection>) => Stream<KeyValueMap<KeyValueMap<any>>>

// Equality comparison
Collection.isEqual: (a: ICollection, b: ICollection) => Boolean
Collection.areItemsEqual: (a: ICollection, b: ICollection) => Boolean

// # COLLECTION INSTANCE TYPES AND METHODS

type ICollection: {
  // Most overloads for the `collection` function are aliases for the `define` method
  define: (fn: ComponentFunction) => ICollection
  define: (type: ComponentTypeIdentifier, fn: ComponentFunction) => ICollection
  define: (type: ComponentTypeIdentifier, options: ComponentDefinition) => ICollection
  define: (options: ComponentDefinition) => ICollection

  // Append an instance of either the default or a specified component type`
  add: (input?: Input) => ICollection
  add: (type: ComponentTypeIdentifier, input: Input) => ICollection
  addInstance: (type?: ComponentTypeIdentifier, instance: Sinks) => ICollection

  // Set a keyed instance of either the default or a specified component type
  set: (key: InstanceIdentifier, type?: ComponentTypeIdentifier, input: Input) => ICollection
  setAt: (index: Number, type?: ComponentTypeIdentifier, input: Input) => ICollection
  setInstance: (key: InstanceIdentifier, type?: ComponentTypeIdentifier, instance: Sinks) => ICollection
  setInstanceAt: (index: Number, type?: ComponentTypeIdentifier, instance: Sinks) => ICollection

  get: (key: InstanceIdentifier) => ComponentInstance
  getAt: (index: Number) => ComponentInstance

  // Remove a component by key or index
  remove: (key: InstanceIdentifier) => ICollection
  removeAt: (index: Number) => ICollection

  // Remove all items from the collection
  clear: () => ICollection

  // The number of elements in the list
  size: Number

  // Collection-specific versions of the same-named static methods
  merge: (key: Key) => Stream
  merge: (keys: Key[]) => Sinks
  combineArray: (key: Key) => Stream<Array<any>>
  combineArray: (keys: Key[]) => Stream<Array<KeyValueMap<any>>>
  combineObject: (key: Key) => Stream<KeyValueMap<any>>
  combineObject: (keys: Key[]) => Stream<KeyValueMap<KeyValueMap<any>>>
}

// A list can be initialized with predefined types and custom behaviour
type ListOptions: {
  sources?: Sources
  types?: [ComponentDefinition, ...]
}

// A component type can be defined with optional custom behaviour per type
type ComponentType: {
  sources?: Sources
  key: ComponentTypeIdentifier
  fn?: ComponentFunction
  instantiate?: (input: Input, type: TypeDefinition, list: ICollection) => Sinks
}

// The following values are retained for each active component instance in the collection:
type ComponentInstance: {
  key: InstanceIdentifier
  type: ComponentTypeIdentifier
  input: Input
  sinks: Sinks
}

Types Reference

This section is yet to be completed.

Package Sidebar

Install

npm i @motorcycle/collection

Weekly Downloads

4

Version

1.1.0

License

MIT

Last publish

Collaborators

  • tylors
  • motorcyclets
  • frikki