Skip to content

Commit

Permalink
Display ingredient error messages on input field
Browse files Browse the repository at this point in the history
Instead of a large blob of information on top of the
element we display a short notice and the error message
on the input field.

Also use HTML5 input validations to show the invalid
state before submitting the data.
  • Loading branch information
tvdeyen committed Jul 31, 2024
1 parent 8a02973 commit 5603b58
Show file tree
Hide file tree
Showing 23 changed files with 200 additions and 182 deletions.
2 changes: 1 addition & 1 deletion app/assets/builds/alchemy/admin.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/builds/alchemy/admin.css.map

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion app/assets/stylesheets/alchemy/admin/elements.scss
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,11 @@ select.long {
bottom: var(--spacing-1);
}
}

.validation-hint {
display: block;
text-align: right;
}
}

div.pictures_for_element {
Expand All @@ -942,11 +947,12 @@ textarea.has_tinymce {
}

.element_errors {
display: flex;
gap: var(--spacing-1);
margin-top: var(--spacing-2);
margin-bottom: var(--spacing-2);
background-color: $error_background_color;
padding: var(--spacing-2);
list-style-type: none;
border-radius: $default-border-radius;
color: $error_text_color;
border: 1px solid $error_border_color;
Expand All @@ -955,6 +961,10 @@ textarea.has_tinymce {
margin: 0;
line-height: 24px;
}

.icon {
fill: currentColor;
}
}

.is-fixed {
Expand Down
4 changes: 4 additions & 0 deletions app/assets/stylesheets/alchemy/admin/forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ form {
}
}

input:invalid {
@extend %field-with-error;
}

small.error {
color: $error_text_color;
display: block;
Expand Down
8 changes: 6 additions & 2 deletions app/controllers/alchemy/admin/elements_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ def update
render json: {
warning: @warning,
errorMessage: Alchemy.t(:ingredient_validations_headline),
ingredientsWithErrors: @element.ingredients_with_errors.map(&:id),
errors: @element.ingredient_error_messages
ingredientsWithErrors: @element.ingredients_with_errors.map do |ingredient|
{
id: ingredient.id,
errorMessage: ingredient.errors.messages[:value].to_sentence
}
end
}, status: :unprocessable_entity
end
end
Expand Down
17 changes: 17 additions & 0 deletions app/decorators/alchemy/ingredient_editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ def deprecation_notice
end
end

def validations
definition.fetch(:validate, [])
end

def format_validation
validations.select { _1.is_a?(Hash) }.find { _1[:format] }&.fetch(:format)
end

def length_validation
validations.select { _1.is_a?(Hash) }.find { _1[:length] }&.fetch(:length)
end

def presence_validation?
validations.include?("presence") ||
validations.any? { _1.is_a?(Hash) && _1[:presence] == true }
end

private

def form_field_counter
Expand Down
39 changes: 15 additions & 24 deletions app/javascript/alchemy_admin/components/element_editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,31 +115,28 @@ export class ElementEditor extends HTMLElement {
/**
* Sets the element to saved state
* Updates title
* JS event bubbling will also update the parents element quote.
* Shows error messages if ingredient validations fail
* @argument {XMLHttpRequest} xhr
*/
onSaveElement(xhr) {
const data = JSON.parse(xhr.responseText)
// JS event bubbling will also update the parents element quote.
this.setClean()
// Reset errors that might be visible from last save attempt
this.errorsDisplay.innerHTML = ""
this.elementErrors.classList.add("hidden")
this.body
.querySelectorAll(".ingredient-editor")
.forEach((el) => el.classList.remove("validation_failed"))
this.setClean()
// If validation failed
if (xhr.status === 422) {
const warning = data.warning
// Create error messages
data.errors.forEach((message) => {
this.errorsDisplay.append(createHtmlElement(`<li>${message}</li>`))
})
// Mark ingredients as failed
data.ingredientsWithErrors.forEach((id) => {
this.querySelector(`[data-ingredient-id="${id}"]`)?.classList.add(
"validation_failed"
data.ingredientsWithErrors.forEach((ingredient) => {
const ingredientEditor = this.querySelector(
`[data-ingredient-id="${ingredient.id}"]`
)
const errorDisplay = createHtmlElement(
`<small class="error">${ingredient.errorMessage}</small>`
)
ingredientEditor?.appendChild(errorDisplay)
ingredientEditor?.classList.add("validation_failed")
})
// Show message
growl(warning, "warn")
Expand Down Expand Up @@ -209,9 +206,12 @@ export class ElementEditor extends HTMLElement {
setClean() {
this.dirty = false
window.onbeforeunload = null
this.elementErrors.classList.add("hidden")

if (this.hasEditors) {
this.body.querySelectorAll(".dirty").forEach((el) => {
el.classList.remove("dirty")
this.body.querySelectorAll(".ingredient-editor").forEach((el) => {
el.classList.remove("dirty", "validation_failed")
el.querySelectorAll("small.error").forEach((e) => e.remove())
})
}
}
Expand Down Expand Up @@ -483,15 +483,6 @@ export class ElementEditor extends HTMLElement {
return this.toggleButton?.querySelector("alchemy-icon")
}

/**
* The error messages container
*
* @returns {HTMLElement}
*/
get errorsDisplay() {
return this.body.querySelector(".error-messages")
}

/**
* The validation messages list container
*
Expand Down
73 changes: 0 additions & 73 deletions app/models/alchemy/element/element_ingredients.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,79 +95,6 @@ def has_value_for?(role)
value_for(role).present?
end

# Ingredient validation error messages
#
# == Error messages are translated via I18n
#
# Inside your translation file add translations like:
#
# alchemy:
# ingredient_validations:
# name_of_the_element:
# role_of_the_ingredient:
# validation_error_type: Error Message
#
# NOTE: +validation_error_type+ has to be one of:
#
# * blank
# * taken
# * invalid
#
# === Example:
#
# de:
# alchemy:
# ingredient_validations:
# contactform:
# email:
# invalid: 'Die Email hat nicht das richtige Format'
#
#
# == Error message translation fallbacks
#
# In order to not translate every single ingredient for every element
# you can provide default error messages per ingredient role:
#
# === Example
#
# en:
# alchemy:
# ingredient_validations:
# fields:
# email:
# invalid: E-Mail has wrong format
# blank: E-Mail can't be blank
#
# And even further you can provide general field agnostic error messages:
#
# === Example
#
# en:
# alchemy:
# ingredient_validations:
# errors:
# invalid: %{field} has wrong format
# blank: %{field} can't be blank
#
def ingredient_error_messages
messages = []
ingredients_with_errors.map { |i| [i.role, i.errors.details] }.each do |role, error_details|
error_details[:value].each do |error_detail|
error = error_detail[:error]
messages << Alchemy.t(
"#{name}.#{role}.#{error}",
scope: "ingredient_validations",
default: [
:"fields.#{role}.#{error}",
:"errors.#{error}"
],
field: Alchemy::Ingredient.translated_label_for(role, name)
)
end
end
messages
end

private

# Builds ingredients for this element as described in the +elements.yml+
Expand Down
3 changes: 1 addition & 2 deletions app/views/alchemy/admin/elements/_element.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@
html: {id: "element_#{element.id}_form".html_safe, class: 'element-body'} do |f| %>

<div id="element_<%= element.id %>_errors" class="element_errors hidden">
<h2><%= Alchemy.t("Validation failed") %></h2>
<alchemy-icon name="alert"></alchemy-icon>
<p><%= Alchemy.t(:ingredient_validations_headline) %></p>
<ul class="error-messages"></ul>
</div>

<!-- Ingredients -->
Expand Down
7 changes: 6 additions & 1 deletion app/views/alchemy/ingredients/_headline_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
<%= ingredient_label(headline_editor) %>

<div class="input-field">
<%= f.text_field :value, id: headline_editor.form_field_id %>
<%= f.text_field :value,
minlength: headline_editor.length_validation&.fetch(:minimum, nil),
maxlength: headline_editor.length_validation&.fetch(:maximum, nil),
required: headline_editor.presence_validation?,
pattern: headline_editor.format_validation,
id: headline_editor.form_field_id %>
<% if headline_editor.settings[:anchor] %>
<%= render "alchemy/ingredients/shared/anchor", ingredient_editor: headline_editor %>
<% end %>
Expand Down
4 changes: 4 additions & 0 deletions app/views/alchemy/ingredients/_link_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
class: "thin_border text_with_icon readonly",
id: link_editor.form_field_id,
"data-link-value": true,
minlength: link_editor.length_validation&.fetch(:minimum, nil),
maxlength: link_editor.length_validation&.fetch(:maximum, nil),
required: link_editor.presence_validation?,
pattern: link_editor.format_validation,
readonly: true,
tabindex: -1
%>
Expand Down
1 change: 1 addition & 0 deletions app/views/alchemy/ingredients/_page_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<%= f.text_field :page_id,
value: page_editor.page&.id,
id: page_editor.form_field_id(:page_id),
required: page_editor.presence_validation?,
class: 'full_width' %>
<% end %>
<% end %>
Expand Down
6 changes: 5 additions & 1 deletion app/views/alchemy/ingredients/_richtext_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

<%- custom_tinymce_config = richtext_editor.custom_tinymce_config.inject({}) { |obj, (k, v)| obj[k.to_s.dasherize] = v.to_json; obj} %>
<%= content_tag("alchemy-tinymce", custom_tinymce_config) do %>
<%= f.text_area :value, id: richtext_editor.form_field_id(:value) %>
<%= f.text_area :value,
minlength: richtext_editor.length_validation&.fetch(:minimum, nil),
maxlength: richtext_editor.length_validation&.fetch(:maximum, nil),
required: richtext_editor.presence_validation?,
id: richtext_editor.form_field_id(:value) %>
<% end %>
<% end %>
<% end %>
3 changes: 2 additions & 1 deletion app/views/alchemy/ingredients/_select_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
<%= f.select :value, options_tags, {}, {
id: select_editor.form_field_id,
class: ["ingredient-editor-select"],
is: "alchemy-select"
is: "alchemy-select",
required: select_editor.presence_validation?
} %>
<% end %>
<% end %>
Expand Down
4 changes: 4 additions & 0 deletions app/views/alchemy/ingredients/_text_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
<%= f.text_field :value,
class: text_editor.settings[:linkable] ? "text_with_icon" : "",
id: text_editor.form_field_id,
minlength: text_editor.length_validation&.fetch(:minimum, nil),
maxlength: text_editor.length_validation&.fetch(:maximum, nil),
required: text_editor.presence_validation?,
pattern: text_editor.format_validation,
type: text_editor.settings[:input_type] || "text" %>
<% if text_editor.settings[:anchor] %>
<%= render "alchemy/ingredients/shared/anchor", ingredient_editor: text_editor %>
Expand Down
22 changes: 0 additions & 22 deletions config/locales/alchemy.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,28 +140,6 @@ en:
recent: Recently uploaded only
without_tag: Without tag

# === Translations for ingredient validations
# Used when a user did not enter (correct) values to the ingredient field.
#
# Tip: You can define the validation messages translations different for each element and ingredient
#
# Example:
#
# en:
# alchemy:
# ingredient_validations:
# contactform:
# success_page:
# blank: 'Please choose a follow up page.'
# mail_to:
# blank: 'Please provide an email address where the contact inquiries will be delivered to.'
#
ingredient_validations:
errors:
blank: "%{field} can't be blank"
invalid: "%{field} has wrong format"
taken: "%{field} has already been taken"

default_ingredient_texts:
lorem: "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
corporate_lorem: "Appropriately enable sustainable growth strategies vis-a-vis holistic materials. Energistically orchestrate open-source e-tailers vis-a-vis plug-and-play best practices. Uniquely plagiarize client-centric opportunities whereas plug-and-play ideas. Distinctively reconceptualize backward-compatible partnerships vis-a-vis reliable total linkage. Interactively fabricate highly efficient networks for clicks-and-mortar content. Collaboratively reconceptualize holistic markets via 2.0 architectures."
Expand Down
Loading

0 comments on commit 5603b58

Please sign in to comment.