-
Notifications
You must be signed in to change notification settings - Fork 360
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 all 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, IMiQRenderCallback } 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: IMiQRenderCallback){} | ||
} | ||
|
||
/** | ||
* 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?: IMiQRenderCallback) { | ||
let newCmp = new ExtensibleComponent(name, api, render); | ||
source.onNext({action: 'add', payload: newCmp}); | ||
return newCmp; | ||
} | ||
|
||
/** | ||
* 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 : {}) | ||
.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)); | ||
|
||
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; | ||
|
||
/** | ||
* 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,38 @@ | ||
export type RenderCallback = (element: HTMLElement) => void; | ||
|
||
export interface IExtensionComponent { | ||
action: string; | ||
payload: any; | ||
} | ||
|
||
export interface IMiQApiCallback { | ||
[propName: string]: Function; | ||
} | ||
|
||
export interface IMiQRenderCallback { | ||
[propName: string]: (renderCallback: RenderCallback) => void; | ||
} | ||
|
||
export interface IExtensibleComponent { | ||
extensibleComponent: any; | ||
apiCallbacks: () => IMiQApiCallback; | ||
renderCallbacks: () => IMiQRenderCallback; | ||
} | ||
|
||
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,79 @@ | ||
describe('Extensible components', function() { | ||
var htmlPartial = '<div>something</div>'; | ||
var cmp; | ||
var mockApi = { | ||
onSomeAction: jasmine.createSpy('onSomeAction', function() {}), | ||
onSomeActionWithParams: jasmine.createSpy('onSomeActionWithParams', function(someParam){}), | ||
}; | ||
|
||
var mockRender = { | ||
addButton: jasmine.createSpy('addButton', function(callback) {}), | ||
addButton2: function(callback) { callback(htmlPartial); } | ||
}; | ||
|
||
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 someCallback = jasmine.createSpy('someCallback', function(element) {}); | ||
subscription.with(function(component) { | ||
component.render.addButton(someCallback); | ||
component.render.addButton2(someCallback); | ||
}); | ||
expect(mockRender.addButton).toHaveBeenCalledWith(someCallback); | ||
expect(someCallback).toHaveBeenCalledWith(htmlPartial); | ||
}); | ||
}); | ||
|
||
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).