Skip to content

Commit

Permalink
Add new JS component API and improve the Redux API
Browse files Browse the repository at this point in the history
  • Loading branch information
vojtechszocs committed Jan 11, 2018
1 parent d030901 commit 2aeaddb
Show file tree
Hide file tree
Showing 13 changed files with 504 additions and 42 deletions.
3 changes: 2 additions & 1 deletion app/assets/javascripts/miq_global.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ if (! window.ManageIQ) {
debounced: {}, // running debounces
debounce_counter: 1,
},
redux: null // Redux API
redux: null, // initialized via 'miq-redux' pack
component: null // initialized via 'miq-component' pack
};
}
144 changes: 144 additions & 0 deletions app/javascript/miq-component/component-typings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Unsubscribe } from 'redux';

/**
* Component definition, serving as a blueprint to create new instances, as well
* as providing access to existing instances.
*
* Use the `create` method to create new instance and (optionally) mount it to
* the given DOM element, depending on whether the component requires DOM context.
*
* Note that the `create` method itself is optional - components may expose their
* existing instances without allowing further instantiation.
*/
export interface ComponentDefinition {

/**
* Name that reflects the component type (must be unique).
*/
name: string;

/**
* Create new instance and (optionally) mount it to the given DOM element.
*
* @param props Used to initialize the component.
* @param mountTo DOM element to mount the component to.
* @param callback Callback for providing component instance.
*
* @returns Function to destroy (and unmount) the component.
*/
create?(props?: any, mountTo?: HTMLElement, callback?: ComponentInstanceCallback): Unsubscribe;

/**
* Get instance of this component, as indicated by its `id`.
*
* @param id Instance `id` to lookup.
*
* @returns Component instance matching the `id` or `undefined` if there is
* no such instance.
*/
getInstance(id: string): ComponentInstance | undefined;

/**
* Get all component instances.
*
* @returns Current snapshot of instances associated with this definition.
*/
getAllInstances(): ComponentInstance[];

}

/**
* Component instance, identified (and lookup-able) by its `id` property.
*
* Each instance may provide an interface for interaction. For example,
* a navigational component might expose `addMenuItem` or similar methods.
* By convention, such interface should be represented by the `interact`
* property.
*/
export interface ComponentInstance {

/**
* Instance `id` is merely a way to distinguish individual component instances
* for better lookup - it's not guaranteed to be unique or follow any specific
* format.
*
* If not defined or not a string, its value will be auto-generated.
*/
id: string;

/**
* Interface for component interaction (optional).
*
* The value is entirely component specific.
*/
interact?: any;

}

/**
* Blueprint used to create and destroy component instances.
*/
export interface ComponentBlueprint {

/**
* Just like the `ComponentDefinition.create` method, minus the cleanup part.
*
* This method *must* return an object that satisfies the `ComponentInstance`
* interface. Otherwise, the `ComponentDefinition.create` method will throw
* an error that indicates a problem with the blueprint.
*
* Note that the returned instance will be sanitized (e.g. ensure proper `id`
* value) prior to adding it to the instance collection.
*/
create?(props?: any, mountTo?: HTMLElement): ComponentInstance;

/**
* Destroy `instance` and unmount it from the given DOM element as necessary.
*/
destroy?(instance?: ComponentInstance, mountFrom?: HTMLElement): void;

}

/**
* Callback used to provide the component instance.
*/
export type ComponentInstanceCallback = (instance: ComponentInstance) => void;

/**
* `ManageIQ.component` API.
*/
export interface ComponentApi {

/**
* Define new component.
*
* Each component has a unique `name`. Attempts to define new component with
* an already taken `name` will have no effect.
*
* @param name Component name (must be unique).
* @param blueprint Blueprint used to create and destroy component instances.
* @param instances Existing instances to associate with this definition.
*
* @returns Definition of the new component or `undefined` if the provided
* `name` is already taken.
*/
define(name: string, blueprint: ComponentBlueprint, instances?: ComponentInstance[]): ComponentDefinition | undefined;

/**
* Get definition of a component, as indicated by its `name`.
*
* @param name Component `name` to lookup.
*
* @returns Component definition matching the `name` or `undefined` if there
* is no such definition.
*/
getDefinition(name: string): ComponentDefinition | undefined;

/**
* Get all component definitions.
*
* @returns Current snapshot of all component definitions.
*/
getAllDefinitions(): ComponentDefinition[];

}
28 changes: 28 additions & 0 deletions app/javascript/miq-component/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { IModule } from 'angular';

import {
sanitizeInstance, instanceCreatorFactory,
define, getDefinition, getAllDefinitions,
clearRegistry
} from './registry';

export default (app: IModule): void => {
// allow unit-testing specific module exports
if (window['jasmine']) {
app.constant('_component_units', {
sanitizeInstance,
instanceCreatorFactory,
define,
getDefinition,
getAllDefinitions,
clearRegistry
});
}

// initialize the namespace (don't wait for application startup)
ManageIQ.component = {
define,
getDefinition,
getAllDefinitions
};
};
110 changes: 110 additions & 0 deletions app/javascript/miq-component/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Unsubscribe } from 'redux';

import { ComponentDefinition, ComponentInstance, ComponentBlueprint, ComponentInstanceCallback } from './component-typings';

const registry: Map<ComponentDefinition, Set<ComponentInstance>> = new Map();

/**
* Sanitize `instance` properties, as defined by the `ComponentInstance` interface.
*/
export function sanitizeInstance(instance: ComponentInstance, definition: ComponentDefinition): void {
if (typeof instance.id !== 'string') {
instance.id = `${definition.name}-${definition.getAllInstances().length}`;
}
}

/**
* Provides `ComponentDefinition.create` method implementations.
*/
export function instanceCreatorFactory(definition: ComponentDefinition, blueprint: ComponentBlueprint):
(props: any, mountTo: HTMLElement, callback: ComponentInstanceCallback) => Unsubscribe {
return (props, mountTo, callback) => {
// construct
const newInstance = blueprint.create(props, mountTo);

// validate
if (!newInstance) {
throw new Error(`blueprint.create returned falsy value during ${definition.name} instance creation`);
}

// sanitize
sanitizeInstance(newInstance, definition);

// add instance to the registry
registry.get(definition).add(newInstance);

// provide instance through callback
typeof callback === 'function' && callback(newInstance);

return () => {
// destroy
typeof blueprint.destroy === 'function' && blueprint.destroy(newInstance, mountTo);

// remove instance from the registry
registry.get(definition).delete(newInstance);
};
}
}

/**
* Implements `ComponentApi.define` method.
*/
export function define(name: string, blueprint: ComponentBlueprint, instances?: ComponentInstance[]): ComponentDefinition | undefined {
if (typeof name !== 'string' || !blueprint || getDefinition(name) !== undefined) {
return;
}

const getAllInstances = () => Array.from(registry.get(newDefinition));

// create component definition
const newDefinition: ComponentDefinition = {
name,
getInstance: (id: string) => getAllInstances().find(instance => instance.id === id),
getAllInstances
};

// add instance creator, if supported by the blueprint
if (typeof blueprint.create === 'function') {
newDefinition.create = instanceCreatorFactory(newDefinition, blueprint);
}

// add definition to the registry
registry.set(newDefinition, new Set());

// add existing instances to the registry
if (Array.isArray(instances)) {
instances.forEach(instance => {
if (instance) {
sanitizeInstance(instance, newDefinition);
registry.get(newDefinition).add(instance);
}
});
}

return newDefinition;
}

/**
* Implements `ComponentApi.getDefinition` method.
*/
export function getDefinition(name: string): ComponentDefinition | undefined {
return getAllDefinitions().find(definition => definition.name === name);
}

/**
* Implements `ComponentApi.getAllDefinitions` method.
*/
export function getAllDefinitions(): ComponentDefinition[] {
return Array.from(registry.keys());
}

/**
* Remove all component data.
*
* Does not call `blueprint.destroy` for existing instances.
*
* *For testing purposes only.*
*/
export function clearRegistry(): void {
registry.clear();
}
44 changes: 23 additions & 21 deletions app/javascript/miq-redux/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import { IModule } from 'angular';

import { configureNgReduxStore } from './store';
import { rootReducer, addReducer, clearReducers, applyReducerHash } from './reducer';

const app = ManageIQ.angular.app;

const initialState = {};

// configure Angular application to use ng-redux
configureNgReduxStore(app, initialState);
export default (app: IModule): void => {
// allow unit-testing specific module exports
if (window['jasmine']) {
app.constant('_redux_units', {
rootReducer,
addReducer,
clearReducers,
applyReducerHash
});
}

// allow unit-testing specific module exports
if (window['jasmine']) {
app.constant('_rootReducer', rootReducer);
app.constant('_addReducer', addReducer);
app.constant('_clearReducers', clearReducers);
app.constant('_applyReducerHash', applyReducerHash);
}
// configure Angular application to use ng-redux
configureNgReduxStore(app, {});

// initialize Redux namespace upon application startup
app.run(['$ngRedux', ($ngRedux) => {
ManageIQ.redux = {
store: $ngRedux,
addReducer,
applyReducerHash
};
}]);
// initialize the namespace upon application startup
app.run(['$ngRedux', ($ngRedux) => {
ManageIQ.redux = {
store: $ngRedux,
addReducer,
applyReducerHash
};
}]);
};
1 change: 0 additions & 1 deletion app/javascript/miq-redux/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Unsubscribe } from 'redux';

import { AppState, AppReducer, Action, AppReducerHash } from './redux-typings';

const reducers: Set<AppReducer> = new Set();
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/miq-redux/store.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { IModule } from 'angular';
import { devToolsEnhancer, EnhancerOptions } from 'redux-devtools-extension/logOnlyInProduction';
import { AppState } from './redux-typings';

import { devToolsEnhancer, EnhancerOptions } from 'redux-devtools-extension/logOnlyInProduction';
import { rootReducer } from './reducer';
import { middlewares } from './middleware';
import { AppState } from './redux-typings';

const devToolsOptions: EnhancerOptions = {};

Expand Down
11 changes: 11 additions & 0 deletions app/javascript/packs/application-common.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@
//
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
// layout file, like app/views/layouts/application.html.erb

import miqRedux from 'miq-redux';
import miqComponent from 'miq-component';

const app = ManageIQ.angular.app;

// TODO(vs) link to article at http://talk.manageiq.org/c/developers
miqRedux(app);

// TODO(vs) link to article at http://talk.manageiq.org/c/developers
miqComponent(app);
1 change: 0 additions & 1 deletion app/javascript/packs/miq-redux-common.js

This file was deleted.

Loading

0 comments on commit 2aeaddb

Please sign in to comment.