Skip to content

Commit

Permalink
Merge pull request #2984 from AlchemyCMS/ingredient-validation-errors…
Browse files Browse the repository at this point in the history
…-inline
  • Loading branch information
tvdeyen authored Aug 1, 2024
2 parents 3613bf0 + 95d7e41 commit 4455010
Show file tree
Hide file tree
Showing 25 changed files with 276 additions and 243 deletions.
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ group :development, :test do
gem "bumpy"
gem "yard"
gem "redcarpet"
gem "pry-byebug"
gem "debug"
gem "listen"
gem "localeapp", "~> 3.0", require: false
gem "dotenv", "~> 3.0"
Expand Down
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.

24 changes: 21 additions & 3 deletions app/assets/stylesheets/alchemy/admin/elements.scss
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ alchemy-publish-element-button {
.ingredient_link_buttons {
display: flex;
position: absolute;
bottom: var(--spacing-2);
bottom: var(--spacing-1);
right: 0;

.icon_button {
Expand Down Expand Up @@ -909,6 +909,19 @@ select.long {
}
}
}

.input-field {
position: relative;

.input-addon {
bottom: var(--spacing-1);
}
}

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

div.pictures_for_element {
Expand All @@ -934,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 @@ -947,6 +961,10 @@ textarea.has_tinymce {
margin: 0;
line-height: 24px;
}

.icon {
fill: currentColor;
}
}

.is-fixed {
Expand Down Expand Up @@ -1008,7 +1026,7 @@ label.ingredient-date--label,
display: inline-flex;
align-items: center;
position: absolute;
bottom: var(--spacing-3);
bottom: var(--spacing-2);
margin: 0 !important;
}

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:focus {
@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
22 changes: 12 additions & 10 deletions app/views/alchemy/ingredients/_datetime_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
data: datetime_editor.data_attributes do %>
<%= element_form.fields_for(:ingredients, datetime_editor.ingredient) do |f| %>
<%= ingredient_label(datetime_editor) %>
<%= alchemy_datepicker(
datetime_editor, :value, {
name: datetime_editor.form_field_name,
id: datetime_editor.form_field_id,
value: datetime_editor.value
}
) %>
<div class="input-field">
<%= alchemy_datepicker(
datetime_editor, :value, {
name: datetime_editor.form_field_name,
id: datetime_editor.form_field_id,
value: datetime_editor.value
}
) %>
<label for="<%= datetime_editor.form_field_id %>" class="ingredient-date--label">
<%= render_icon "calendar" %>
</label>
</div>
<% end %>
<label for="<%= datetime_editor.form_field_id %>" class="ingredient-date--label">
<%= render_icon "calendar" %>
</label>
<% end %>
51 changes: 29 additions & 22 deletions app/views/alchemy/ingredients/_headline_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,39 @@
data: headline_editor.data_attributes do %>
<%= element_form.fields_for(:ingredients, headline_editor.ingredient) do |f| %>
<%= ingredient_label(headline_editor) %>
<%= f.text_field :value, id: headline_editor.form_field_id %>

<% if headline_editor.settings[:anchor] %>
<%= render "alchemy/ingredients/shared/anchor", ingredient_editor: headline_editor %>
<% end %>
<div class="input-field">
<%= 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 %>

<div class="input-addon right<%= " second" if has_size_select %>">
<sl-tooltip content="<%= f.object.class.human_attribute_name(:level) %>">
<%= f.select :level,
options_for_select(headline_editor.level_options, headline_editor.level),
{},
{
class: "custom-select",
disabled: !has_level_select
} %>
</sl-tooltip>
</div>

<% if has_size_select %>
<div class="input-addon right">
<sl-tooltip content="<%= f.object.class.human_attribute_name(:size) %>">
<%= f.select :size, options_for_select(headline_editor.size_options, headline_editor.size),
<div class="input-addon right<%= " second" if has_size_select %>">
<sl-tooltip content="<%= f.object.class.human_attribute_name(:level) %>">
<%= f.select :level,
options_for_select(headline_editor.level_options, headline_editor.level),
{},
{ class: "custom-select" } %>
{
class: "custom-select",
disabled: !has_level_select
} %>
</sl-tooltip>
</div>
<% end %>

<% if has_size_select %>
<div class="input-addon right">
<sl-tooltip content="<%= f.object.class.human_attribute_name(:size) %>">
<%= f.select :size, options_for_select(headline_editor.size_options, headline_editor.size),
{},
{ class: "custom-select" } %>
</sl-tooltip>
</div>
<% end %>
</div>
<% end %>
<% end %>
Loading

0 comments on commit 4455010

Please sign in to comment.