-
Notifications
You must be signed in to change notification settings - Fork 35
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
Nested errors #10
Comments
As I reported in railsgsoc#10, error keys get duplicated and we should namespace them. This code behaves right like AR::Base accept_nested_attributes
Probably we should try to correlate the attribute with its model. That would be more descriptive. |
What do you mean? |
Oh, sorry. At this moment it's fixed in #11 |
Hi guys, please consider a look at rails/rails#8638. TL;DRThe errors output must be something like this: class ProductForm < ActiveForm::Base
attributes :name
association :versions do
attributes :name
end
end {
errors: {
name: ["can't be blank"],
versions: {
relation_errors: ["can't have more than 5"],
record_errors: {
0: { name: "can't be blank" },
3: { name: "can't be blank" }
}
}
}
} ExplanationWhen you have the errors output being consumed by an API (i.e.: Backbone), you definitely must have the nested object index to identify which record has the problem. Using this object you would have access to association errors at You may ask why class Product < ActiveRecord::Base
has_many :versions
validate :must_have_at_least_one_version
def must_have_at_least_one_version
errors.add(:versions, :at_least_one) if versions.reject(&:marked_for_destruction?).empty?
end
end With that in mind, you have errors at the relation and errors at the records of the relation. The latter is a painful point on rails today because you can't detect which record of the sent records has the issue, example:
You can't answer if the white color or the black color was taken, or both. What we've been doing today is using a custom presenter on rails applications to get the complete error output, like this (draft, wip): class BackboneErrorsPresenter
attr_reader :object, :klass
def initialize(object)
@object = object
@klass = object.class
end
def to_h
errors = HashWithIndifferentAccess.new
klass.reflect_on_all_associations.each do |association|
if association.macro == :has_many && association.options[:autosave]
errors[association.name] = {}
object.send(association.name).each_with_index do |child, index|
errors[association.name][:records_errors] ||= {}
errors[association.name][:records_errors][index] = child.errors.to_h
end
end
end
object.errors.each do |attribute, error|
next if attribute =~ /\./
if klass.reflect_on_association(attribute)
errors[attribute] ||= {}
errors[attribute][:relation_errors] = error
else
errors[attribute] = error
end
end
{ :errors => errors }
end
end Using this object, we have the desired output: def create
@product = Product.new(params[:product])
if @product.save
render :json => @product
else
render :json => BackboneErrorsPresenter.new(@product).to_h, :status => :unprocessable_entity
end
end It's working on production right now with backbone + backbone-nested-attributes + our plugin (draft, wip): Application.Model = Backbone.NestedAttributesModel.extend({
initialize: function () {
this.computeHasOneRelations();
this.computeHasManyRelations();
this.clearErrors();
this.on("request", this.clearErrors);
this.on("error", this.parseErrors);
},
computeHasOneRelations: function () {
this.hasOneRelations = _.where(this.relations, { type: "one" });
},
computeHasManyRelations: function () {
this.hasManyRelations = _.where(this.relations, { type: "many" });
},
clearErrors: function () {
this.clearMyErrors();
this.clearHasOneErrors();
this.clearHasManyErrors();
},
clearMyErrors: function () {
this.errors = {};
},
clearHasOneErrors: function () {
_.chain(this.hasOneRelations)
.map(function (relation) { return this.get(relation.key) }, this)
.invoke("clearErrors");
},
clearHasManyErrors: function () {
_.chain(this.hasManyRelations)
.map(function (relation) { return this.get(relation.key) }, this)
.pluck("models")
.flatten()
.invoke("clearErrors");
},
validate: function () {
return _.isEmpty(this.errors) && this.validateChildren();
},
validateChildren: function () {
return this.validateHasManyChildren() && this.validateHasOneChildren();
},
validateHasManyChildren: function () {
return _.chain(this.hasManyRelations)
.map(function (relation) { return this.get(relation.key) }, this)
.map(function (collection) { return collection.models })
.flatten()
.any(function (model) { return !model.isValid() })
.value();
},
validateHasOneChildren: function () {
return _.chain(this.hasOneRelations)
.map(function (relation) { return this.get(relation.key) }, this)
.any(function (model) { return !model.isValid() })
.value();
},
parseErrors: function (model, jqXHR) {
if (!jqXHR.responseJSON || !jqXHR.responseJSON.errors) {
return;
}
this.attachErrors(jqXHR.responseJSON.errors);
},
attachErrors: function (errors) {
this.attachMyErrors(errors);
this.attachRelationsErrors(errors);
this.trigger("validated", this);
},
attachMyErrors: function (errors) {
_.each(errors, function (error, attribute) {
if (_.isObject(error)) {
error = error.relation_errors;
}
if (error) {
this.errors[attribute] = error;
}
}, this);
},
attachRelationsErrors: function (errors) {
this.attachHasOneRelationsErrors(errors);
this.attachHasManyRelationsErrors(errors);
},
attachHasOneRelationsErrors: function (errors) {
_.each(this.hasOneRelations, function (relation) {
var relationErrors = errors[relation.key];
if (relationErrors) {
this.attachErrorsInRelation(relation, relationErrors);
}
}, this);
},
attachHasManyRelationsErrors: function (errors) {
_.each(this.hasManyRelations, function (relation) {
var relationErrors = errors[relation.key];
if (relationErrors) {
this.attachErrorsInRelations(relation, relationErrors);
}
}, this);
},
attachErrorInRelation: function (relation, errors) {
var model = this.get(relation.key);
if (model.attachErrors) {
model.attachErrors(errors.records_errors);
}
},
attachErrorsInRelations: function (relation, errors) {
var collection = this.get(relation.key);
collection.forEach(function (model, index) {
if (errors.records_errors[index] && model.attachErrors) {
model.attachErrors(errors.records_errors[index]);
}
});
}
}); And the model: Product = Application.Model.extend({
urlRoot: "/products",
relations: [
{
type: "many",
key: "versions",
relatedModel: function () { return Application.Model }
}
]
}); We are going to finish all these objects and package it all on a ruby gem (there is 2 more objects which integrates all of this on marionette with a behaviour and a POJO to deal with the DOM). My point is: every nested endpoint needs this error output or another one which provides the relation errors and the record errors with the index. |
It makes sense, but as far as Active Form is just an extraction from Active Record with few additions, we can't break the stable AR::Base API. |
Are we going to replace active record nested attributes? I think it's a good time make this change since it's backwards incompatible, rails 5 is on the way. If compatibility is a concern, we can make it opt-in. |
As I reported in railsgsoc#10, error keys get duplicated and we should namespace them. This code behaves right like AR::Base accept_nested_attributes
As I reported in railsgsoc#10, error keys get duplicated and we should namespace them. This code behaves right like AR::Base accept_nested_attributes
As I reported in railsgsoc#10, error keys get duplicated and we should namespace them. This code behaves right like AR::Base accept_nested_attributes
My form:
When I submit it with such params:
Emails errors are:
I find it weird that email errors are duplicated even if the fields have similar name.
Should we return a hash of errors to avoid it?
The text was updated successfully, but these errors were encountered: