Closed sirbrillig closed 7 years ago
@westonruter Another question I had, regarding the code under test: Is it a good idea to allow adding arbitrary properties to the object via the constructor argument? It seems a little bit like mixing two inheritance mechanisms in one place: the Form
can be subclassed, but also you can override its properties and methods using the object passed in the constructor like a mixin.
var form = new Form({
foo: 'bar',
sanitize: function( val ) { return _.extend({}, val, {hello: 'world'}); }
} );
form.foo; // 'bar'
form.sanitize( {} ); // {hello: 'world'}
@sirbrillig good question. It's probably not good form to do that. What do you suggest? Mark certain properties as being protected and throw Error
if they are attempted to be overridden? Or some other refactoring of Form
?
@westonruter hm... I guess my first question is: what is the "normal" or "expected" use-case of that feature? That is, what are the intended arguments to the constructor?
I'll make a guess (but I could definitely be missing something): it's intended to allow setting/overriding id_base
, model
, container
, and (certain properties of) config
. If that's true, then I would say to just copy over those properties explicitly, and silently ignore any other properties in the parameter object.
I could see the benefit of throwing if there's an unexpected property, in that it might make it easier to find bugs or typos, but I could also imagine a situation where the argument object is constructed by something else and passed into the constructor programmatically and we might want to just ignore unexpected properties. Also, the constructor does already throw if the required properties are not present.
So I could go either way, but my instinct would be to ignore unexpected properties and explicitly assign expected ones. What do you think?
So I could go either way, but my instinct would be to ignore unexpected properties and explicitly assign expected ones. What do you think?
@sirbrillig I think you're right. I think I initially was allowing anything to be passed in because it wasn't used enough to see clearly what the usage patterns should be. Really, the only params that absolutely would need to be passed in which are unique to each instance would be model
and container
. The id_base
, default_instance
, and other config parameters really should be defined on the subclass prototype
itself.
So then for PHP should export its config once. And actually, this is already the case for the core widgets that are implemented: https://github.com/xwp/wp-js-widgets/blob/38d8298528737add34594efdb4db8d649355a140/php/class-wp-adapter-js-widget.php#L85-L89
And looking at the existing usages for admin screen and customizer pane, only the model
and container
properties are being used:
So yeah, let's update Form
to only honor passing a model
and container
args, and we can also let Form
be marked as @abstract
to then also throw errors if initialize
is called and this
doesn't have id_base
or default_config
inherited from the prototype.
Does that make sense?
@westonruter that makes sense. I added tests to that effect and then changed the constructor.
Another thing I just noticed (which might not actually be relevant) is that because Form
overrides its model.validate
method with a closure that includes methods on the Form
, the model will keep a reference to that Form
even after the Form is destroyed. I don't know where the model comes from or if it lives beyond the Form's existence, but maybe we should restore the original validate method in destruct
?
Alternatively, if this is a concern, instead of overriding the model.validate
method, we could just run our own validation in the form change event handler before the data is sent to the model. Fewer mutations of external objects would reduce the chance for errors to creep in. That is, unless it's expected that the model might receive data from another source which would require the modified validation.
I added a test to show the memory leak in case we want to change anything later.
@sirbrillig:
Another thing I just noticed (which might not actually be relevant) is that because Form overrides its model.validate method with a closure that includes methods on the Form, the model will keep a reference to that Form even after the Form is destroyed. I don't know where the model comes from or if it lives beyond the Form's existence, but maybe we should restore the original validate method in destruct?
Oh yeah, that's a good point! Yeah, the model
can indeed live beyond the Form
's existence and so restoring the original validate
method should indeed be done in the form is destructed.
But actually, it would probably be even better to create a new Value
instance inside the Form
's constructor and then to sync
this new value with the supplied model. This new Value
instance could then have its own unique validate
method, and when the Form
is destructed it can just be unsync
'ed and destroyed.
So inside of initialize
:
form.model = new api.Value();
form.model.validate = function( value ) {
// Current logic in our overridden validate method.
return value;
};
form.model.set( args.model.get );
form._externalModel = args.model;
form.model.sync( form._externalModel );
And then in destruct
:
form.model.unsync( form._externalModel );
form.model = null;
This approach will keep the original args.model
object pristine and it will also prevent a memory leak.
Alternatively, if this is a concern, instead of overriding the model.validate method, we could just run our own validation in the form change event handler before the data is sent to the model. Fewer mutations of external objects would reduce the chance for errors to creep in. That is, unless it's expected that the model might receive data from another source which would require the modified validation.
In this case it would just mean that the form should be forbidden from directly accessing this.model
, right? Subclasses would be restricted to only using form.getValue()
. This would make sense to me actually. And then the form.setState()
method would then be what implements the validate
logic? The model
shouldn't be expected to be modified from another place outside of the Form
, or if it is, we can assume that it is already validated (for example, populating the customizer settings with a previous revision).
In this case it would just mean that the form should be forbidden from directly accessing this.model, right? Subclasses would be restricted to only using form.getValue().
Right. I think I like this approach the most because it's just less complexity (and one less thing for the subclass to "know about"), even though the model duplication/sync/unsync approach would certainly work.
@sirbrillig ok, sounds good! You want to incorporate that into this PR?
@westonruter I'm fine to do that unless you'd like to keep the change separate?
As part of the changes I simplified two-way data binding a bit (see 16f9762). I'm not sure if the resulting simplified version covers everything that the sync
version did. The primary difference is that we're only listening to the change
event, rather than propertychange
, keyup
, and input
. There's lots of other ways to sync the data so if this doesn't work we can try something else.
@sirbrillig oh, I didn't consider how createSyncedPropertyValue
related here. A problem with change
is that it will only trigger when the user has blurred the field, resulting in a poorer preview experience. This is what wp.customize.Element
is supposed to abstract away, using the appropriate DOM events to listen for changes to the underlying input
.
I think that createSyncedPropertyValue
needs to be restored, but with a change to how sync
is done. Inside of propertyChangeListener
we can do this change instead:
--- a/js/widget-form.js
+++ b/js/widget-form.js
@@ -272,15 +272,15 @@ wp.widgets.Form = (function( api, $ ) {
* @returns {object} Property value instance.
*/
createSyncedPropertyValue: function createSyncedPropertyValue( root, property ) {
- var propertyValue, rootChangeListener, propertyChangeListener;
+ var form = this, propertyValue, rootChangeListener, propertyChangeListener;
propertyValue = new api.Value( root.get()[ property ] );
// Sync changes to the property back to the root value.
propertyChangeListener = function( newPropertyValue ) {
- var rootValue = _.clone( root.get() );
- rootValue[ property ] = newPropertyValue;
- root.set( rootValue );
+ var newState = {};
+ newState[ property ] = newPropertyValue;
+ form.setState( newState );
};
propertyValue.bind( propertyChangeListener );
This will ensure that the model
is not directly mutated so that our validate
logic will still apply.
Does that make sense?
Ah, nice. Don't know why I didn't see that part of the function before. I guess I wasn't clear what was going on in there. I made the changes you suggested and the tests all pass. 👍 (I rebased away the old version.)
One last question:
In renderNotificationsToContainer
, this command is run to change the CSS of the notifications container after it is shown: container.css( 'height', 'auto' );
. I'm curious why that's only done when the container is shown and it's not changed when it's hidden? Could the height just be part of the actual CSS for the Form instead of being set with javascript? I guess that also makes me wonder: why not do the animation with CSS instead of jQuery?
Ah, nice. Don't know why I didn't see that part of the function before. I guess I wasn't clear what was going on in there. I made the changes you suggested and the tests all pass.
Great!
I'm curious why that's only done when the container is shown and it's not changed when it's hidden? Could the height just be part of the actual CSS for the Form instead of being set with javascript? I guess that also makes me wonder: why not do the animation with CSS instead of jQuery?
@sirbrillig Humm, I don't have a good answer for you. I can't remember why height:auto
was added 😦 If you see no good reason for it, the I'd say to go ahead and remove. The styling/animation logic here is copied from wp.customize.Control#renderNotifications
. If you have a CSS solution then more power to you! But I think that CSS transitions/animations weren't used because they are not supported by IE<11.
TIL that CSS transitions aren't available in IE < 10. It's amazing how quickly I've come to think of them as natural. Well, I don't know why height: auto
is in there either, but it's a relatively minor thing either way. I'd be inclined to just remove it and re-add it if/when it becomes apparent that there's a problem that needs to be solved.
Great! I'll give this PR a test soon and see if there's any issues on my end and then merge. Excellent work here. Really appreciate the depth you went and the insights you have.
@sirbrillig I found that the widgets weren't getting id_base
defined on the widget prototypes, so I added 5d7ccb0. This means a widget should be defined via:
wp.widgets.formConstructor.foo = wp.widgets.Form.extend({
id_base: 'foo'
});
So there is some redundancy there. In reality they should never be different, so it seems a shame to duplicate.
So there is some redundancy there. In reality they should never be different, so it seems a shame to duplicate.
I'm not sure I understand. Do you mean, the id_base
property should always be the same as the name of the property or variable to which that object is assigned?
So const HelloWidget = Form.extend( {} )
should result in HelloWidget.id_base === 'HelloWidget'
?
I'm not sure I understand. Do you mean, the id_base property should always be the same as the name of the property or variable to which that object is assigned?
id_base
entirely 😄 I removed id_base
from Form
. Let me know if there's anything else!
Add basic JavaScript unit tests for
wp.widgets.Form
. They can be run usingnpm test
on the command-line (you may need to runnpm install
first to get the tests properly set up).This also makes some changes to the
wp.widgets.Form
class to make the code more concise and more testable. There are also a few bugs that are fixed. Changes include:_.clone
Form
as a module when within CommonJSForm.initialize
to deep merge config with defaultsdefault_instance
with the model in all places exceptForm.getValue
title
property to model inForm.sanitize
Form.model.validate
methodForm.initialize
constructorForm.renderNotificationsToContainer
to allow testingForm.renderNotifications
Form.model.validate
This needs to be tested with a real-world Form to be sure that the above changes do not break anything.
There's also more simplifications that could be made, but I wanted to get a PR up for the purposes of discussion.