Create a type definitions file for the client-side JavaScript API
See original GitHub issueThis issue is for discussing and creating a types definitions file for the JavaScript API of PrimeFaces - with the goal of enabling people to write more maintainable, less error-prone client side code for dynamic pages with PrimeFaces, and to provide a better coding experience with better tooling.
Typescript is fully supported as type definition files are provided in the npm package of PrimeReact. A sample typescript-primereact application is available as well at github.
What and why
This issue is asking for TypeScript type defintions (rather than say Flow) as TypeScript has gotten pretty popular these days.
To quote from the forum post I made:
We are using PrimeFaces for a complex application with many features, some of which require customizing components and/or writing client-side JavaScript for interacting with existing PrimeFaces components. New possibilities such as splitting the code into ES6 modules or using transpilers like closure compiler help a lot to prevent the JavaScript from becoming a mess.
When I need to interact with PrimeFaces components, I need to know which JavaScript functions there are. Some are documented on the help pages, but that is far from complete. I can open the dev tools and check it out, but doing so every time is cumbersome. So I decided to write some TypeScript type definitions for the part of the JavaScript API I needed. To give you an idea of how this looks:
(removed, see below)
TypeScript type definitions are already available for a lot of node packages and many newer libraries provide them themselves. This has several advantages:
- For the Java part, when we update to a newer of PrimeFaces, the compiler checks which part of the API was modified and identifies the code needs to be adapted. Currently, this is not possible for the client-side JavaScript API. Updating PrimeFaces requires checking manually whether that results in any JavaScript errors. Since it’s usually not possible to test all code paths, this remains incomplete and bugs are introduced easily. With a type-definitions file, much more checking could be done before the JavaScript code is even sent to the browser.
- It also makes the JavaScript API easier to discover and prevents misspelling properties and function names. Right now, discovering the JavaScript API means opening up the console.
- It also tells people explicitly which part of the API is not public (by virtue of not being included). With the right tools, this can also be checked. Right now, that is up to anybody’s guess.
- The type-definitions files can also be used for documentation (DocComments, similar to Java). Tools like typedoc can even generate some nice-looking API docs from typescript (definition) files.
Ideally, it would be great if PrimeFaces would come bundled with with a type definitions file that people could use. Perhaps I might be able to help out with an initial type definitions file, but in the long run, the PrimeFaces team needs to maintain it.
Generating the type definitions
One idea that was mentioned in the forum was to generate the type definitions directly from the JavaScript source files. This is a great idea and eases the amount of effort required, but unfortunately there’s no such thing as a free lunch. Generating the type definitions completely auto-magically from the JavaScript source is (afaik) not possible (but see the suggestion below). A few reasons include:
- Fields (properties on a new class-like object) are not spelled out explicitly. Some may even be added to the object dynamically within some setup method (and that is very common for widgets, especially as they have no
constructor() {}
- Method (functions) may refer to the special variable
arguments
(which should not be used anymore in favor of varargsfunction foo(x, ..y){}
). But if they’re used, that makes it nearly impossible to tell what they are and how many there can be. - Optional arguments are not marked. When an argument is optional, a method usually attempts to assign some default value to it in the function body (though that would be easier with modern JavaScript
function foo(x, y, z = 3) }{}
). - And at any rate, figuring out the intended type of the arguments as well as the return type, just from the JavaScript source, is not possible. And this is half of the reason of having type definitions.
- (Plain-text English) comments need to be added to the methods, arguments, and property manually either way.
So we need to put in some extra effort to create and maintain a type definitions file. In my opinion it is well worth it, and that is why I opened this issue.
Maven and node
Depending of how we approach this, TypeScript is written in JavaScript (node.js) and many tools are written in JavaScript as well. Chances are we might need to make use of some of these tools in some manner during the build process.
There is a very nice maven plugin that I use in my projects as well. It is called maven-frontend-plugin and basically installs a completely local version of node
in the target
directory during the build process. It works with Windows and Linux (and probably macOS as well, though I haven’t tried it personally) and makes it easy to integrate a node
build task with maven.
A first suggestion
First of all, one way to achieve this involves a rather major change and I don’t know if people would welcome it. For the sake of completeness, let me mention it though:
- Rewrite the JavaScript source code to TypeScript. This would mainly involve adding type annotations and using
class
es instead ofPrimeFaces.widget.BaseWidget.extend({...})
. - Then a type definitions file would be generated during the transpilation process from TS to JS.
- As a plus, your JavaScript code gets type-checked as well.
JavaScript API
The JavaScript API consists of two main parts:
- The core functions in
META-INF/resources/primefaces/core
, consisting of the modules- ajax
- csp
- dialog
- env
- expression
- utils
- widgets
- The individual widgets in
META-INF/resources/primefaces/...
The lion’s share the API is represented the widgets. I count about ~99 widget folders. I looked through some of them and they seem to be maintained by a handful of different author. Each widget is a class and consists of three parts:
- Direct fields (or properties), such as
PrimeFaces.widget.LightBox.navigators
These are often JQuery elements that hold the HTML elements the widgets are made of. - Properties in the
cfg
object, such asPrimeFaces.widget.LightBox.cfg.mode
. These are usually generated on the server and sent to the client, and are thus mostly primitive types such as strings and boolean. Sometimes also holds callback functions etc. - Methods, such as
PrimeFaces.widget.LightBox.show()
. Most of these methods are “action methods”, ie. methods that take no or only a few basic arguments, perform some action (eg. showing or hiding a dialog), and return nothing. Some are also simple getters (and a few setters), such asPrimeFaces.widget.LightBox.isVisible()
. The point to note here is that most methods are rather simple, as far as type annotations are concerned, and if streamline the development process, we should streamline it this case.
Additionally, some fields, configuration properties and methods are private and not meant to be used from the outside, while others are public and are part of the public API. Right now, there is no to little indication which are which. Sometimes internal methods and properties start with an underscore _
, but not always.
This is one reason a component maintainer should review the relevant part of the type definitions file before it is released to the public. I think we should also establish a few guidelines on which property and methods should be exposed, as there are many common types of properties and methods between widgets, see above.
How to proceed
I’d say how best to proceed depends on how comfortable these authors are with TypeScript definitions in general.
I can think of two similar ideas:
- For each widget, create a new
.d.ts
type definitions file, preferably in the same directory as the widget.- This keeps the JavaScript and the type definitions completely separate, and makes them completely optional.
- On the other hand, widget maintainers (and the occasional contributor) now need to know about two files. It’s also easy for the two files to drift apart, especially when someone updates the widget and forgets to update the type definitions as well.
- Include the comments and types directly in the widget file where they belong; by adding JSDoc-like comments (similar to JavaDocs).
- This has the advantage of keeping the method defintions and the docs/comments closely together, reducing the risk of somebody forgetting to update them.
- It also reduces the amount of TypeScript that needs to be written (as it omits the
interface
s andextend
s etc.), which may be helpful for people not comfortable with TypeScript. - It means a little bit more work to implement though. I would probably parse the JavaScript files with a parser such as acorn, extract and parse the JSDoc comments, and generate the type definitions file for the widget from that. Would also need a few custom tags such as
@cfg
to indicate some widget specific information.
Regarding the first idea, this seems like how they’re doing it over at primereact
For the second idea, here is a quick draft of how it could look like for the LightBox
widget.
At the top of the file you can see some additional TypeScript definitions. For the LightBox
widget, they are not necessary and could be inlined. But in general it may become necessary and should be supported. Although we could also make a separate file for those few cases. Also, observer how some methods are marked @internal
to exclude them from the type definitions file.
Example of how inline comments and type annotations could look like
/**
* @ts
*
* interface ShowUrlOptions {
* height: number;
* width: number;
* }
*
*
* type Mode = "image" | "inline"| "iframe";
*/
/**
* PrimeFaces LightBox Widget.
* @field {JQuery} links Element that contains the links.
* @field {JQuery} panel Element with the dialog panel <-- THIS IS ADDED DYNAMICALLY IN A METOHD. ONE REASON WE CANNOT GENERATE TYPEDEFS COMPLETELY AUTOMATICALLY
* @field {JQuery} content ...
* @field {JQuery} imageDisplay ...
* @field {JQuery} navigators ...
* @field {JQuery} iframe ...
* @field {JQuery} imageDisplay ...
* @field {JQuery} caption ...
* @field {JQuery} captionText ...
* @field {JQuery} closeIcon ...
* @field {(() => void)[]} onshowHandlers ...
* @cfg {string} appendTo Search expression for the element to which the dialog should be appended.
* @cfg {Mode} mode The mode of this lightbox component.
* @cfg {boolean} visible Whether the lightbox dialog is currently visible.
* @cfg {string} width CSS length with the width of the dialog.
* @cfg {string} height CSS length with the height of the dialog.
* @cfg {JQuery} iframeTitle
* @cfg {() => void} onShow
* @cfg {() => void} onHide
*/
PrimeFaces.widget.LightBox = PrimeFaces.widget.BaseWidget.extend({
/**
* Main initialization method for the widget. Creates the LightBox and binds the events.
* @param {PrimeFaces.WidgetCfg.LightBoxCfg} cfg
*/
init: function(cfg) {
// the dynamic overlay must be appended to the body
cfg.appendTo = '@(body)';
this._super(cfg);
this.links = this.jq.children(':not(.ui-lightbox-inline)');
this.createPanel();
if(this.cfg.mode === 'image') {
this.setupImaging();
} else if(this.cfg.mode === 'inline') {
this.setupInline();
} else if(this.cfg.mode === 'iframe') {
this.setupIframe();
}
this.bindCommonEvents();
if(this.cfg.visible) {
this.links.eq(0).click();
}
},
/**
* Refreshes the dynamic overlay dialog with LightBox. Called during an AJAX update.
* Use this when...
* @param {PrimeFaces.WidgetCfg.LightBoxCfg} cfg
* @override
*/
refresh: function(cfg) {
PrimeFaces.utils.removeDynamicOverlay(this, this.panel, this.id + '_panel', $(document.body));
this._super(cfg);
},
/**
* Creates the panel.
* @internal
*/
createPanel: function() {
this.panel = $('<div id="' + this.id + '_panel" class="ui-lightbox ui-widget ui-helper-hidden ui-corner-all ui-shadow">'
+ '<div class="ui-lightbox-content-wrapper">'
+ '<a class="ui-state-default ui-lightbox-nav-left ui-corner-right ui-helper-hidden"><span class="ui-icon ui-icon-carat-1-w">go</span></a>'
+ '<div class="ui-lightbox-content ui-corner-all"></div>'
+ '<a class="ui-state-default ui-lightbox-nav-right ui-corner-left ui-helper-hidden"><span class="ui-icon ui-icon-carat-1-e">go</span></a>'
+ '</div>'
+ '<div class="ui-lightbox-caption ui-widget-header"><span class="ui-lightbox-caption-text"></span>'
+ '<a class="ui-lightbox-close ui-corner-all" href="#"><span class="ui-icon ui-icon-closethick"></span></a><div style="clear:both" /></div>'
+ '</div>');
PrimeFaces.utils.registerDynamicOverlay(this, this.panel, this.id + '_panel');
this.contentWrapper = this.panel.children('.ui-lightbox-content-wrapper');
this.content = this.contentWrapper.children('.ui-lightbox-content');
this.caption = this.panel.children('.ui-lightbox-caption');
this.captionText = this.caption.children('.ui-lightbox-caption-text');
this.closeIcon = this.caption.children('.ui-lightbox-close');
},
/**
* Does setup of imaging.
* @interal
*/
setupImaging: function() {
var _self = this;
this.content.append('<img class="ui-helper-hidden"></img>');
this.imageDisplay = this.content.children('img');
this.navigators = this.contentWrapper.children('a');
this.imageDisplay.on('load', function() {
var image = $(this);
_self.scaleImage(image);
//coordinates to center overlay
var leftOffset = (_self.panel.width() - image.width()) / 2,
topOffset = (_self.panel.height() - image.height()) / 2;
//resize content for new image
_self.content.removeClass('ui-lightbox-loading').animate({
width: image.width()
,height: image.height()
},
500,
function() {
//show image
image.fadeIn();
_self.showNavigators();
_self.caption.slideDown();
});
_self.panel.animate({
left: '+=' + leftOffset
,top: '+=' + topOffset
}, 500);
});
this.navigators.mouseover(function() {
$(this).addClass('ui-state-hover');
})
.mouseout(function() {
$(this).removeClass('ui-state-hover');
})
.click(function(e) {
var nav = $(this);
_self.hideNavigators();
if(nav.hasClass('ui-lightbox-nav-left')) {
var index = _self.current == 0 ? _self.links.length - 1 : _self.current - 1;
_self.links.eq(index).trigger('click');
}
else {
var index = _self.current == _self.links.length - 1 ? 0 : _self.current + 1;
_self.links.eq(index).trigger('click');
}
e.preventDefault();
});
this.links.click(function(e) {
var link = $(this);
if(_self.isHidden()) {
_self.content.addClass('ui-lightbox-loading').width(32).height(32);
_self.show();
}
else {
_self.imageDisplay.fadeOut(function() {
//clear for onload scaling
$(this).css({
'width': 'auto'
,'height': 'auto'
});
_self.content.addClass('ui-lightbox-loading');
});
_self.caption.slideUp();
}
setTimeout(function() {
_self.imageDisplay.attr('src', link.attr('href'));
_self.current = link.index();
var title = link.attr('title');
if(title) {
_self.captionText.text(title);
}
}, 1000);
e.preventDefault();
});
},
/**
* Scales the image.
* @param {JQuery} image
*/
scaleImage: function(image) {
var win = $(window),
winWidth = win.width(),
winHeight = win.height(),
imageWidth = image.width(),
imageHeight = image.height(),
ratio = imageHeight / imageWidth;
if(imageWidth >= winWidth && ratio <= 1){
imageWidth = winWidth * 0.75;
imageHeight = imageWidth * ratio;
}
else if(imageHeight >= winHeight){
imageHeight = winHeight * 0.75;
imageWidth = imageHeight / ratio;
}
image.css({
'width':imageWidth + 'px'
,'height':imageHeight + 'px'
});
},
/**
* Does setup of inline.
* @internal
*/
setupInline: function() {
this.inline = this.jq.children('.ui-lightbox-inline');
this.inline.appendTo(this.content).show();
var _self = this;
this.links.click(function(e) {
_self.show();
var title = $(this).attr('title');
if(title) {
_self.captionText.text(title);
_self.caption.slideDown();
}
e.preventDefault();
});
},
/**
* Does setup of iframe.
* @internal
*/
setupIframe: function() {
var $this = this;
this.iframeLoaded = false;
this.cfg.width = this.cfg.width||'640px';
this.cfg.height = this.cfg.height||'480px';
this.iframe = $('<iframe frameborder="0" style="width:' + this.cfg.width + ';height:'
+ this.cfg.height + ';border:0 none; display: block;"></iframe>').appendTo(this.content);
if(this.cfg.iframeTitle) {
this.iframe.attr('title', this.cfg.iframeTitle);
}
this.links.click(function(e) {
if(!$this.iframeLoaded) {
$this.content.addClass('ui-lightbox-loading').css({
width: $this.cfg.width
,height: $this.cfg.height
});
$this.show();
$this.iframe.on('load', function() {
$this.iframeLoaded = true;
$this.content.removeClass('ui-lightbox-loading');
})
.attr('src', $this.links.eq(0).attr('href'));
}
else {
$this.show();
}
var title = $this.links.eq(0).attr('title');
if(title) {
$this.captionText.text(title);
$this.caption.slideDown();
}
e.preventDefault();
});
},
/**
* Binds all events.
* @internal
*/
bindCommonEvents: function() {
var $this = this;
this.closeIcon.mouseover(function() {
$(this).addClass('ui-state-hover');
})
.mouseout(function() {
$(this).removeClass('ui-state-hover');
});
this.closeIcon.click(function(e) {
$this.hide();
e.preventDefault();
});
var hideEvent = PrimeFaces.env.ios ? 'touchstart' : 'click';
PrimeFaces.utils.registerHideOverlayHandler(this, hideEvent + '.' + this.id + '_hide', $this.panel,
function() { return $this.links.add($this.closeIcon); },
function(e, eventTarget) {
if(!($this.panel.is(eventTarget) || $this.panel.has(eventTarget).length > 0)) {
e.preventDefault();
$this.hide();
}
});
PrimeFaces.utils.registerResizeHandler(this, 'resize.' + this.id + '_align', $this.panel, function() {
$(document.body).children('.ui-widget-overlay').css({
'width': $(document).width()
,'height': $(document).height()
});
});
},
/**
* Shows the lightbox.
*/
show: function() {
this.center();
this.panel.css('z-index', ++PrimeFaces.zindex).show();
if(!PrimeFaces.utils.isModalActive(this.id)) {
this.enableModality();
}
if(this.cfg.onShow) {
this.cfg.onShow.call(this);
}
},
/**
* Hides the lightbox
*/
hide: function() {
this.panel.fadeOut();
this.disableModality();
this.caption.hide();
if(this.cfg.mode == 'image') {
this.imageDisplay.hide().attr('src', '').removeAttr('style');
this.hideNavigators();
}
if(this.cfg.onHide) {
this.cfg.onHide.call(this);
}
},
/**
* Centers the lightbox.
*/
center: function() {
var win = $(window),
left = (win.width() / 2 ) - (this.panel.width() / 2),
top = (win.height() / 2 ) - (this.panel.height() / 2);
this.panel.css({
'left': left,
'top': top
});
},
/**
* Makes the dialog modal.
*/
enableModality: function() {
PrimeFaces.utils.addModal(this, this.panel.css('z-index') - 1);
},
/**
* Makes the dialog not modal.
*/
disableModality: function() {
PrimeFaces.utils.removeModal(this);
},
/**
* Shows the navigation buttons.
*/
showNavigators: function() {
this.navigators.zIndex(this.imageDisplay.zIndex() + 1).show();
},
/**
* Hides the navigation buttons.
*/
hideNavigators: function() {
this.navigators.hide();
},
/**
* Adds the event handlers.
* @param {() => void} fn The handler to append to the existing handlers.
* @internal
*/
addOnshowHandler: function(fn) {
this.onshowHandlers.push(fn);
},
/**
* @param {boolean} `true` if the lightbox dialog is currently closed, `false` otherwise.
*/
isHidden: function() {
return this.panel.is(':hidden');
},
/**
* Shows the given URL.
* @param {ShowUrlOptions} opt
*/
showURL: function(opt) {
if(opt.width)
this.iframe.attr('width', opt.width);
if(opt.height)
this.iframe.attr('height', opt.height);
this.iframe.attr('src', opt.src);
this.captionText.text(opt.title||'');
this.caption.slideDown();
this.show();
}
});
The core of the API is smaller, does not change as much and is (probably?) maintained by less people. Here we could just make a separate type definitions file. Or, if the approach mentioned above works out well and can be adapted, add the type definitions inline as well.
What’s next
I would love to hear your ideas and opinions on this. I’ve got some experience with TypeScript and could contribute a bit towards an initial type definitions file.
Before it’s released, I think it would be prudent to let the widget maintainers have a look, to catch errors and mistakes and to check which part of the API should be exposed.
Further down the road, you could also consider publishing the type definitions to the DefinitelyTyped repository. This would make it a lot easier for people who use npm
to consume and work with it. It would also be possible to generate the JavaScript API docs directly from the type definitions via tools such as typedoc. The PrimeFaces doc pages for each widget could then just link to the relevant page of the JavaScript API doc pages
Issue Analytics
- State:
- Created 4 years ago
- Reactions:1
- Comments:23 (23 by maintainers)
Alright, I went ahead and made a prototype (needs a bit of work + refactoring) for the second option to see if it could work. I came up with the following:
.d.ts
type definitions fileFor example, I can now run the following:
which results in this output
Click to expand and view maven output
I also took the first widget,
AccordionPanel
, and added some doc comments so you can see how it will look like:Click to expand and view accordion.js
This results in the following type definitions file:
Click to expand and view accordion.d.ts
The script itself parses the JavaScript source code and checks the doc comments for type annotations. This worked out pretty nice:
.d.ts
file, switching is easy as you can just take the generated file and edit that.@private
tag.What do you think? In case you like it, the next step would be to go ahead and add some actual type definitions to the widget JavaScript files.
PS: Here’s a pastebin of the script run against all widgets. All type annotations are of course still
any
.Yes lets leave it
strict
it caught me where my params were not right and I didn’t have a return statement.