-
Notifications
You must be signed in to change notification settings - Fork 359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WIP] Add extensible components API to create components which can expose API and render endpoints #2060
[WIP] Add extensible components API to create components which can expose API and render endpoints #2060
Changes from 4 commits
6d7d3e2
3ddc111
400e0a7
0bc1675
1aaa1e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
declare var ManageIQ: any; | ||
declare var Rx: any; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import { IExtensionComponent, IMiQApiCallback } from './lib'; | ||
|
||
const source = new Rx.Subject(); | ||
const items = new Set(); | ||
|
||
/** | ||
* Class for easy creation of extensible component. | ||
*/ | ||
export class ExtensibleComponent { | ||
public delete: () => void; | ||
constructor(public name: string, public api: IMiQApiCallback, public render: IMiQApiCallback){} | ||
} | ||
|
||
/** | ||
* Helper function to create new component. | ||
* @param name string name of new component. | ||
* @param api callback functions to change inner logic of component. | ||
* @param render callback function to apply render functions. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at MiQ plugin example using this PR, I assume the render object serves the purpose of visual (DOM level) integration, correct? However, I'd really suggest using the concept of render functions instead of DOM element getters. A render function is a function which takes a DOM element and renders something into it:
The
Example code using a render function:
Any interaction that doesn't involve DOM should be done through API object (above). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd put both There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, the
The For example, imagine an Angular component with following DOM:
The functions are designed as callbacks, which gives the component control over when they are called, because the component itself knows best when to call them, and if to call them at all. If we expose the component's DOM element (e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For simple html changes, it's good. However if some plugin would like to use their own render function (for example react) we'd loose this option. I'd allow both of these functions:
Developer of such plugin has to know that using such functions can break something and it's his responsibility to change something. However with second function we have a lot of advantages so we should consider if we really don't want to use it. For me it feels less invasive to pass placeholder element and let plugin render some stuff inside it, rather than doing something like this: let placeholder = document.createElement('div');
ReactDOM.render(<someCmp />, placeholder);
var subs = subscribe('toolbar');
subs.with((component) => component.renderSomeContent(placeholder)); Where However I understand that the second option for complex examples should not be part of render object, rather to be part of API object. So the question here is. Do we want to render just plain HTML partials or let plugins render it themselves via their own logic? I'd go for both options. |
||
*/ | ||
function addComponent(name: string, api?: IMiQApiCallback, render?: IMiQApiCallback) { | ||
let newCmp = new ExtensibleComponent(name, api, render); | ||
source.onNext({action: 'add', payload: newCmp}); | ||
return newCmp; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need the return value, and if so, why? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When creating and adding new component new component will also has |
||
} | ||
|
||
/** | ||
* Helper function to subscribe to extension items based on component's name. | ||
* @param cmpName name of component we want to subscribe to. | ||
* @return object which has with and unsubscribe property. With is for callback to use found component and delete to | ||
* unsubscribe from rxjs subject. | ||
*/ | ||
function subscribe(cmpName: string) { | ||
let unsubscribe; | ||
return { | ||
with: (callback) => { | ||
unsubscribe = source | ||
.map(sourceAction => sourceAction.action === 'add' ? sourceAction.payload : {}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's only The general idea is that e.g. Angular component would make itself extensible within its There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unsubscribe function is part of created component, so by calling |
||
.filter(component => component && component.name === cmpName) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question, why do we need the Edit: OK, I see that we do this to ensure |
||
.subscribe(cmp => cmp && callback(cmp)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code is to catch any future additions of given extension, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is for plugins and other pieces of JS to say that they want to have control over certain component with given name. If any component with let's say |
||
|
||
ManageIQ.extensions.items.forEach((component) => { | ||
component.name === cmpName && callback(component); | ||
}); | ||
}, | ||
delete: () => unsubscribe && unsubscribe.dispose() | ||
} | ||
} | ||
|
||
const extensions = { | ||
addComponent, | ||
subscribe, | ||
get items() { return items; } | ||
} | ||
|
||
ManageIQ.extensions = ManageIQ.extensions || extensions; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we should use webpack import (e.g.) import {subscribe, addComponent} from 'extensible-component'; IMHO we should expose it as a global var, but going forward I think its better to leverage webpack as you would get compile errors and live reloading when needed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The idea was that MiQ APIs are primarily exposed through globals, e.g. Given that, we also defined client libraries like (I'd prefer those client libraries to be named There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @vojtechszocs again, I'm not against exposing those API's but legacy / others, but I wonder if its better to use import / export with webpack vs relay on window (for example for testing, live reload functionality etc). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ohadlevy normally you'd not use the globals, you'd just use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The idea behind this is that both redux and extensible component will be npm packages, pulled either from rails plugin or just included in About the global variables, that's mostly just for testing/legacy reasons. Plus when bundling webpack packages right now we'd duplicate a lot of code. So let's say we have 2 plugins both are using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks @himdel I think we are both agreeing on all of your points. I think what I'm saying, is that within webpack managed code, we should default to import vs using the globals. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ohadlevy the biggest problem here is that both redux and extensible component code is not bundled as a library. They sit in ui-classic for easier development and are treated as general packs. But once we have proper setup of these two libraries (plus commons chunk plugin set up in ui-components) and they'll be npm packages we (and any plugin using webpack) can import them as you said. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Agreed on that. We use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @karelhala why is it different if its a npm package or not? as long as its being exported (via an npm package or on ui-classic/webpack) it should work just the same. ideally, going forward, new code will land in webpack control, so it shouldnt be really used that much (e.g. using globals). when you actually move it to a library, all you would need to change is your import line, e.g. import { something } from './some-pack' to import { something } from 'some-package' There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we are in circles and talking about same things. Yes if we are in webpack managed file we will favor webpack imports over global access variables. That's why we have lib files here - you will include addNewComponent which will reference to global variable, and once we move all files to webpack managed world we can just change the lib files in a way that they will use imported functions as wel. |
||
|
||
/** | ||
* Subscribe to extensions source to add new components to items object. | ||
*/ | ||
source.subscribe((component: IExtensionComponent) => { | ||
if (component.action === 'add' && component.hasOwnProperty('payload')) { | ||
component.payload.delete = () => ManageIQ.extensions.items.delete(component.payload); | ||
ManageIQ.extensions.items.add(component.payload); | ||
} else { | ||
console.error('Unsupported action with extension components.'); | ||
} | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
export interface IExtensionComponent { | ||
action: string; | ||
payload: any; | ||
} | ||
|
||
export interface IMiQApiCallback { | ||
[propName: string]: Function; | ||
} | ||
|
||
export interface IExtensibleComponent { | ||
extensibleComponent: any; | ||
apiCallbacks: () => IMiQApiCallback; | ||
renderCallbacks: () => IMiQApiCallback; | ||
} | ||
|
||
export function getItems() { | ||
return ManageIQ.extensions.items; | ||
} | ||
|
||
export function subscribe(cmpName: string) { | ||
return ManageIQ.extensions.subscribe(cmpName); | ||
} | ||
|
||
/** | ||
* Helper function to create new component. | ||
* @param name string name of new component. | ||
* @param api callback functions to change inner logic of component. | ||
* @param render callback function to apply render functions. | ||
*/ | ||
export function addComponent(name: string, api?: IMiQApiCallback, render?: IMiQApiCallback) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, we could also do:
to cut down the boilerplate code. But I'm not sure how TypeScript fits the above syntax form 😕 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can, however I am not fan of assigning functions to const. So to be consistent we should all three things export as functions There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @karelhala just wondering - why you are not a fan? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ohadlevy well mostly due to readability. I really like fat arrows, but for named functions I prefer the "old" way instead of assign to const 😄 function getTen() {
return 10;
} Is somewhat more readable and from first glance you see that it's function, instead when reading this const getTen = () => 10; It looks nice and all, but the person reading this has to know that it's function assignment into const. Plus binding is kinda strange with the second one. function printA() {
console.log(this.a);
}
const printA2 = () => console.log(this.a)
printA.bind({a: 'someText'})();
printA2.bind({a: 'someText'})(); The second one ignores binding of object, I know that it's the reason how There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that lambdas declared through From variable scoping point of view,
Right, lambdas don't have their own There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Exactly, but const getTen = () => 10;
typeof(getTen); Will return |
||
return ManageIQ.extensions.addComponent(name, api, render); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
require('../extensible-components'); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
describe('Extensible components', function() { | ||
var cmp; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q. why var vs const, does it not support babel? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tests are not taken care by webpack or anything, so that's why we can't use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tests should be treated with same love and care as production code. Good testing tools do the JavaScript transpilation for you, so you don't have to mess with your existing webpack configuration. When using Jest, just add @martinpovolny @karelhala BTW, Jest includes Jasmine as its test runner. |
||
var mockApi = { | ||
onSomeAction: jasmine.createSpy('onSomeAction', function() {}), | ||
onSomeActionWithParams: jasmine.createSpy('onSomeActionWithParams', function(param) {}), | ||
}; | ||
|
||
var mockRender = { | ||
addButtonHtml: jasmine.createSpy('addButtonHtml', function(htmlElem) {}) | ||
}; | ||
|
||
it('should be defined with empty items', function() { | ||
expect(ManageIQ.extensions).toBeDefined(); | ||
expect(ManageIQ.extensions.items.size).toBe(0); | ||
}); | ||
|
||
it('should create one item', function() { | ||
cmp = ManageIQ.extensions.addComponent('testCmp', mockApi, mockRender); | ||
expect(ManageIQ.extensions.items.size).toBe(1); | ||
}); | ||
|
||
it('should remove newly created item', function() { | ||
cmp.delete(); | ||
expect(ManageIQ.extensions.items.size).toBe(0); | ||
}); | ||
|
||
describe('default actions', function() { | ||
var subscription; | ||
var someParam = 'something'; | ||
|
||
beforeEach(function() { | ||
cmp = ManageIQ.extensions.addComponent('testCmp', mockApi, mockRender); | ||
}); | ||
|
||
describe('subscription', function() { | ||
it('should subscribe based on name', function() { | ||
subscription = ManageIQ.extensions.subscribe('testCmp'); | ||
expect(subscription.delete).toBeDefined(); | ||
expect(subscription.with).toBeDefined(); | ||
}); | ||
|
||
it('should react to API', function() { | ||
subscription.with(function(component) { | ||
component.api.onSomeAction(); | ||
component.api.onSomeActionWithParams(someParam); | ||
}); | ||
expect(mockApi.onSomeAction).toHaveBeenCalled(); | ||
expect(mockApi.onSomeActionWithParams).toHaveBeenCalledWith(someParam); | ||
}); | ||
|
||
it('should react to render', function() { | ||
var someHTML = '<div>something</div>'; | ||
subscription.with(function(component) { | ||
component.render.addButtonHtml(someHTML); | ||
}); | ||
expect(mockRender.addButtonHtml).toHaveBeenCalledWith(someHTML); | ||
}); | ||
}); | ||
|
||
it('should not subscribe', function() { | ||
subscription = ManageIQ.extensions.subscribe('somethingBad'); | ||
subscription.with(function(component) { | ||
//callback should not be called! | ||
expect(false).toBe(true); | ||
}); | ||
expect(subscription.with).toBeDefined(); | ||
expect(subscription.delete).toBeDefined(); | ||
}); | ||
|
||
afterEach(function() { | ||
cmp.delete(); | ||
subscription && subscription.delete(); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API object is an object that defines interaction with the given component.
For example, an extensible toolbar component would expose API object providing
getItems
method. Or providingaddItem
method that adds new item to the toolbar component.Interaction through API object is DOM agnostic. For interaction on the DOM level, one should use the render object (below).