Skip to content

Commit 2aeaddb

Browse files
committed
Add new JS component API and improve the Redux API
1 parent d030901 commit 2aeaddb

File tree

13 files changed

+504
-42
lines changed

13 files changed

+504
-42
lines changed

app/assets/javascripts/miq_global.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ if (! window.ManageIQ) {
8484
debounced: {}, // running debounces
8585
debounce_counter: 1,
8686
},
87-
redux: null // Redux API
87+
redux: null, // initialized via 'miq-redux' pack
88+
component: null // initialized via 'miq-component' pack
8889
};
8990
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { Unsubscribe } from 'redux';
2+
3+
/**
4+
* Component definition, serving as a blueprint to create new instances, as well
5+
* as providing access to existing instances.
6+
*
7+
* Use the `create` method to create new instance and (optionally) mount it to
8+
* the given DOM element, depending on whether the component requires DOM context.
9+
*
10+
* Note that the `create` method itself is optional - components may expose their
11+
* existing instances without allowing further instantiation.
12+
*/
13+
export interface ComponentDefinition {
14+
15+
/**
16+
* Name that reflects the component type (must be unique).
17+
*/
18+
name: string;
19+
20+
/**
21+
* Create new instance and (optionally) mount it to the given DOM element.
22+
*
23+
* @param props Used to initialize the component.
24+
* @param mountTo DOM element to mount the component to.
25+
* @param callback Callback for providing component instance.
26+
*
27+
* @returns Function to destroy (and unmount) the component.
28+
*/
29+
create?(props?: any, mountTo?: HTMLElement, callback?: ComponentInstanceCallback): Unsubscribe;
30+
31+
/**
32+
* Get instance of this component, as indicated by its `id`.
33+
*
34+
* @param id Instance `id` to lookup.
35+
*
36+
* @returns Component instance matching the `id` or `undefined` if there is
37+
* no such instance.
38+
*/
39+
getInstance(id: string): ComponentInstance | undefined;
40+
41+
/**
42+
* Get all component instances.
43+
*
44+
* @returns Current snapshot of instances associated with this definition.
45+
*/
46+
getAllInstances(): ComponentInstance[];
47+
48+
}
49+
50+
/**
51+
* Component instance, identified (and lookup-able) by its `id` property.
52+
*
53+
* Each instance may provide an interface for interaction. For example,
54+
* a navigational component might expose `addMenuItem` or similar methods.
55+
* By convention, such interface should be represented by the `interact`
56+
* property.
57+
*/
58+
export interface ComponentInstance {
59+
60+
/**
61+
* Instance `id` is merely a way to distinguish individual component instances
62+
* for better lookup - it's not guaranteed to be unique or follow any specific
63+
* format.
64+
*
65+
* If not defined or not a string, its value will be auto-generated.
66+
*/
67+
id: string;
68+
69+
/**
70+
* Interface for component interaction (optional).
71+
*
72+
* The value is entirely component specific.
73+
*/
74+
interact?: any;
75+
76+
}
77+
78+
/**
79+
* Blueprint used to create and destroy component instances.
80+
*/
81+
export interface ComponentBlueprint {
82+
83+
/**
84+
* Just like the `ComponentDefinition.create` method, minus the cleanup part.
85+
*
86+
* This method *must* return an object that satisfies the `ComponentInstance`
87+
* interface. Otherwise, the `ComponentDefinition.create` method will throw
88+
* an error that indicates a problem with the blueprint.
89+
*
90+
* Note that the returned instance will be sanitized (e.g. ensure proper `id`
91+
* value) prior to adding it to the instance collection.
92+
*/
93+
create?(props?: any, mountTo?: HTMLElement): ComponentInstance;
94+
95+
/**
96+
* Destroy `instance` and unmount it from the given DOM element as necessary.
97+
*/
98+
destroy?(instance?: ComponentInstance, mountFrom?: HTMLElement): void;
99+
100+
}
101+
102+
/**
103+
* Callback used to provide the component instance.
104+
*/
105+
export type ComponentInstanceCallback = (instance: ComponentInstance) => void;
106+
107+
/**
108+
* `ManageIQ.component` API.
109+
*/
110+
export interface ComponentApi {
111+
112+
/**
113+
* Define new component.
114+
*
115+
* Each component has a unique `name`. Attempts to define new component with
116+
* an already taken `name` will have no effect.
117+
*
118+
* @param name Component name (must be unique).
119+
* @param blueprint Blueprint used to create and destroy component instances.
120+
* @param instances Existing instances to associate with this definition.
121+
*
122+
* @returns Definition of the new component or `undefined` if the provided
123+
* `name` is already taken.
124+
*/
125+
define(name: string, blueprint: ComponentBlueprint, instances?: ComponentInstance[]): ComponentDefinition | undefined;
126+
127+
/**
128+
* Get definition of a component, as indicated by its `name`.
129+
*
130+
* @param name Component `name` to lookup.
131+
*
132+
* @returns Component definition matching the `name` or `undefined` if there
133+
* is no such definition.
134+
*/
135+
getDefinition(name: string): ComponentDefinition | undefined;
136+
137+
/**
138+
* Get all component definitions.
139+
*
140+
* @returns Current snapshot of all component definitions.
141+
*/
142+
getAllDefinitions(): ComponentDefinition[];
143+
144+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { IModule } from 'angular';
2+
3+
import {
4+
sanitizeInstance, instanceCreatorFactory,
5+
define, getDefinition, getAllDefinitions,
6+
clearRegistry
7+
} from './registry';
8+
9+
export default (app: IModule): void => {
10+
// allow unit-testing specific module exports
11+
if (window['jasmine']) {
12+
app.constant('_component_units', {
13+
sanitizeInstance,
14+
instanceCreatorFactory,
15+
define,
16+
getDefinition,
17+
getAllDefinitions,
18+
clearRegistry
19+
});
20+
}
21+
22+
// initialize the namespace (don't wait for application startup)
23+
ManageIQ.component = {
24+
define,
25+
getDefinition,
26+
getAllDefinitions
27+
};
28+
};
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Unsubscribe } from 'redux';
2+
3+
import { ComponentDefinition, ComponentInstance, ComponentBlueprint, ComponentInstanceCallback } from './component-typings';
4+
5+
const registry: Map<ComponentDefinition, Set<ComponentInstance>> = new Map();
6+
7+
/**
8+
* Sanitize `instance` properties, as defined by the `ComponentInstance` interface.
9+
*/
10+
export function sanitizeInstance(instance: ComponentInstance, definition: ComponentDefinition): void {
11+
if (typeof instance.id !== 'string') {
12+
instance.id = `${definition.name}-${definition.getAllInstances().length}`;
13+
}
14+
}
15+
16+
/**
17+
* Provides `ComponentDefinition.create` method implementations.
18+
*/
19+
export function instanceCreatorFactory(definition: ComponentDefinition, blueprint: ComponentBlueprint):
20+
(props: any, mountTo: HTMLElement, callback: ComponentInstanceCallback) => Unsubscribe {
21+
return (props, mountTo, callback) => {
22+
// construct
23+
const newInstance = blueprint.create(props, mountTo);
24+
25+
// validate
26+
if (!newInstance) {
27+
throw new Error(`blueprint.create returned falsy value during ${definition.name} instance creation`);
28+
}
29+
30+
// sanitize
31+
sanitizeInstance(newInstance, definition);
32+
33+
// add instance to the registry
34+
registry.get(definition).add(newInstance);
35+
36+
// provide instance through callback
37+
typeof callback === 'function' && callback(newInstance);
38+
39+
return () => {
40+
// destroy
41+
typeof blueprint.destroy === 'function' && blueprint.destroy(newInstance, mountTo);
42+
43+
// remove instance from the registry
44+
registry.get(definition).delete(newInstance);
45+
};
46+
}
47+
}
48+
49+
/**
50+
* Implements `ComponentApi.define` method.
51+
*/
52+
export function define(name: string, blueprint: ComponentBlueprint, instances?: ComponentInstance[]): ComponentDefinition | undefined {
53+
if (typeof name !== 'string' || !blueprint || getDefinition(name) !== undefined) {
54+
return;
55+
}
56+
57+
const getAllInstances = () => Array.from(registry.get(newDefinition));
58+
59+
// create component definition
60+
const newDefinition: ComponentDefinition = {
61+
name,
62+
getInstance: (id: string) => getAllInstances().find(instance => instance.id === id),
63+
getAllInstances
64+
};
65+
66+
// add instance creator, if supported by the blueprint
67+
if (typeof blueprint.create === 'function') {
68+
newDefinition.create = instanceCreatorFactory(newDefinition, blueprint);
69+
}
70+
71+
// add definition to the registry
72+
registry.set(newDefinition, new Set());
73+
74+
// add existing instances to the registry
75+
if (Array.isArray(instances)) {
76+
instances.forEach(instance => {
77+
if (instance) {
78+
sanitizeInstance(instance, newDefinition);
79+
registry.get(newDefinition).add(instance);
80+
}
81+
});
82+
}
83+
84+
return newDefinition;
85+
}
86+
87+
/**
88+
* Implements `ComponentApi.getDefinition` method.
89+
*/
90+
export function getDefinition(name: string): ComponentDefinition | undefined {
91+
return getAllDefinitions().find(definition => definition.name === name);
92+
}
93+
94+
/**
95+
* Implements `ComponentApi.getAllDefinitions` method.
96+
*/
97+
export function getAllDefinitions(): ComponentDefinition[] {
98+
return Array.from(registry.keys());
99+
}
100+
101+
/**
102+
* Remove all component data.
103+
*
104+
* Does not call `blueprint.destroy` for existing instances.
105+
*
106+
* *For testing purposes only.*
107+
*/
108+
export function clearRegistry(): void {
109+
registry.clear();
110+
}

app/javascript/miq-redux/index.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
1+
import { IModule } from 'angular';
2+
13
import { configureNgReduxStore } from './store';
24
import { rootReducer, addReducer, clearReducers, applyReducerHash } from './reducer';
35

4-
const app = ManageIQ.angular.app;
5-
6-
const initialState = {};
7-
8-
// configure Angular application to use ng-redux
9-
configureNgReduxStore(app, initialState);
6+
export default (app: IModule): void => {
7+
// allow unit-testing specific module exports
8+
if (window['jasmine']) {
9+
app.constant('_redux_units', {
10+
rootReducer,
11+
addReducer,
12+
clearReducers,
13+
applyReducerHash
14+
});
15+
}
1016

11-
// allow unit-testing specific module exports
12-
if (window['jasmine']) {
13-
app.constant('_rootReducer', rootReducer);
14-
app.constant('_addReducer', addReducer);
15-
app.constant('_clearReducers', clearReducers);
16-
app.constant('_applyReducerHash', applyReducerHash);
17-
}
17+
// configure Angular application to use ng-redux
18+
configureNgReduxStore(app, {});
1819

19-
// initialize Redux namespace upon application startup
20-
app.run(['$ngRedux', ($ngRedux) => {
21-
ManageIQ.redux = {
22-
store: $ngRedux,
23-
addReducer,
24-
applyReducerHash
25-
};
26-
}]);
20+
// initialize the namespace upon application startup
21+
app.run(['$ngRedux', ($ngRedux) => {
22+
ManageIQ.redux = {
23+
store: $ngRedux,
24+
addReducer,
25+
applyReducerHash
26+
};
27+
}]);
28+
};

app/javascript/miq-redux/reducer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Unsubscribe } from 'redux';
2-
32
import { AppState, AppReducer, Action, AppReducerHash } from './redux-typings';
43

54
const reducers: Set<AppReducer> = new Set();

app/javascript/miq-redux/store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { IModule } from 'angular';
2-
import { devToolsEnhancer, EnhancerOptions } from 'redux-devtools-extension/logOnlyInProduction';
2+
import { AppState } from './redux-typings';
33

4+
import { devToolsEnhancer, EnhancerOptions } from 'redux-devtools-extension/logOnlyInProduction';
45
import { rootReducer } from './reducer';
56
import { middlewares } from './middleware';
6-
import { AppState } from './redux-typings';
77

88
const devToolsOptions: EnhancerOptions = {};
99

app/javascript/packs/application-common.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,14 @@
66
//
77
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
88
// layout file, like app/views/layouts/application.html.erb
9+
10+
import miqRedux from 'miq-redux';
11+
import miqComponent from 'miq-component';
12+
13+
const app = ManageIQ.angular.app;
14+
15+
// TODO(vs) link to article at http://talk.manageiq.org/c/developers
16+
miqRedux(app);
17+
18+
// TODO(vs) link to article at http://talk.manageiq.org/c/developers
19+
miqComponent(app);

app/javascript/packs/miq-redux-common.js

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)