Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/assets/builds/alchemy/alchemy_admin.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/builds/alchemy/alchemy_admin.min.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/components/alchemy/admin/element_editor.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<% end %>

<% if editable? %>
<%= form_for [alchemy, :admin, element], remote: true,
<%= form_for [alchemy, :admin, element],
html: {id: "element_#{id}_form".html_safe, class: 'element-body'} do |f| %>

<div id="element_<%= id %>_errors" class="element_errors hidden">
Expand Down
11 changes: 10 additions & 1 deletion app/components/alchemy/ingredients/base_editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def call
concat ingredient_id_field
concat ingredient_label
concat input_field
concat validation_error
end
end

Expand Down Expand Up @@ -101,10 +102,18 @@ def css_classes
partial_name,
ingredient.deprecated? ? "deprecated" : nil,
settings[:linkable] ? "linkable" : nil,
settings[:anchor] ? "with-anchor" : nil
settings[:anchor] ? "with-anchor" : nil,
ingredient.errors[:value].any? ? "validation_failed" : nil
].compact
end

# Renders the inline validation error shown below a failing ingredient input.
def validation_error
return if ingredient.errors[:value].empty?

tag.small(ingredient.errors[:value].to_sentence, class: "error")
end

def data_attributes
{
ingredient_id: ingredient.id,
Expand Down
1 change: 1 addition & 0 deletions app/components/alchemy/ingredients/boolean_editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def call
render_hint_for(ingredient, size: "1x", fixed_width: false)
])
}
concat validation_error
end
end
end
Expand Down
27 changes: 4 additions & 23 deletions app/controllers/alchemy/admin/elements_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,31 +56,12 @@ def create
# Updates the element and all ingredients in the element.
#
def update
if @element.update(element_params)
render json: {
notice: Alchemy.t(:element_saved),
previewText: Rails::Html::SafeListSanitizer.new.sanitize(@element.preview_text),
ingredientAnchors: @element.ingredients.filter_map do |ingredient|
if ingredient.settings[:anchor]
{
ingredientId: ingredient.id,
active: ingredient.dom_id.present?
}
end
end
}.merge(pagePublicationData(@element.page))
@element_validated = @element.update(element_params)
if @element_validated
render :update
else
@warning = Alchemy.t("Validation failed")
render json: {
warning: @warning,
errorMessage: Alchemy.t(:ingredient_validations_headline),
ingredientsWithErrors: @element.ingredients_with_errors.map do |ingredient|
{
id: ingredient.id,
errorMessage: ingredient.errors.messages[:value].to_sentence
}
end
}, status: 422
render :update, status: 422
end
end

Expand Down
6 changes: 0 additions & 6 deletions app/javascript/alchemy_admin/components/button.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@ class Button extends HTMLButtonElement {
connectedCallback() {
if (this.form) {
this.form.addEventListener("submit", this)

if (this.form.dataset.remote == "true") {
this.form.addEventListener("ajax:complete", this)
}

this.form.addEventListener("turbo:submit-end", this)
} else {
console.warn("No form for button found!", this)
Expand All @@ -27,7 +22,6 @@ class Button extends HTMLButtonElement {
this.disable()
}
break
case "ajax:complete":
case "turbo:submit-end":
this.enable()
break
Expand Down
74 changes: 24 additions & 50 deletions app/javascript/alchemy_admin/components/element_editor.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
import { post } from "alchemy_admin/utils/ajax"
import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
import { dispatchPageDirtyEvent } from "alchemy_admin/utils/dispatch_page_dirty_event"
import { growl } from "alchemy_admin/growler"

import "alchemy_admin/components/element_editor/publish_element_button"
Expand All @@ -22,12 +19,12 @@ export class ElementEditor extends HTMLElement {
this.addEventListener("click", this)
// Triggered by child elements
this.addEventListener("alchemy:element-update-title", this)
// We use of @rails/ujs for Rails remote forms
this.addEventListener("ajax:complete", this)

this.#form = this.form
if (this.#form) {
this.#form.addEventListener("change", this.onChange)
this.#form.addEventListener("turbo:submit-start", this)
this.#form.addEventListener("turbo:submit-end", this)
}

this.#header = this.header
Expand All @@ -49,9 +46,10 @@ export class ElementEditor extends HTMLElement {
disconnectedCallback() {
this.removeEventListener("click", this)
this.removeEventListener("alchemy:element-update-title", this)
this.removeEventListener("ajax:complete", this)
if (this.#form) {
this.#form.removeEventListener("change", this.onChange)
this.#form.removeEventListener("turbo:submit-start", this)
this.#form.removeEventListener("turbo:submit-end", this)
this.#form = null
}
this.#header?.removeEventListener("dblclick", this.#onHeaderDblclick)
Expand All @@ -68,12 +66,11 @@ export class ElementEditor extends HTMLElement {
this.onClickElement()
}
break
case "ajax:complete":
if (event.target === this.body) {
const xhr = event.detail[0]
event.stopPropagation()
this.onSaveElement(xhr)
}
case "turbo:submit-start":
this.setClean()
break
case "turbo:submit-end":
this.onSaveElement(event.detail.success)
break
case "alchemy:element-update-title":
if (!this.hasEditors && event.target == this.firstChild) {
Expand Down Expand Up @@ -126,46 +123,20 @@ 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)
// Reset errors that might be visible from last save attempt
this.setClean()
// If validation failed
if (xhr.status === 422) {
const warning = data.warning
// Create error messages
// Mark ingredients as 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")
this.elementErrors.classList.remove("hidden")
} else {
growl(data.notice)
* Applies the client-side effects of a save once the turbo stream rendered.
*
* The notice, error markup, header title, anchor icons and publish button are
* server-rendered turbo streams. Only the preview refresh (which must focus
* this element afterwards) and the error box toggle remain client-side.
* @argument {boolean} success
*/
onSaveElement(success) {
if (success) {
this.previewWindow?.refresh().then(() => {
this.focusElementPreview()
})
this.updateTitle(data.previewText)
data.ingredientAnchors.forEach((anchor) => {
IngredientAnchorLink.updateIcon(anchor.ingredientId, anchor.active)
})
if (data.pageHasUnpublishedChanges) {
dispatchPageDirtyEvent(data)
}
} else {
this.elementErrors.classList.remove("hidden")
}
}

Expand Down Expand Up @@ -565,10 +536,13 @@ export class ElementEditor extends HTMLElement {
/**
* The form element if present
*
* Scoped to this element's own form so a wrapper element without ingredients
* does not return a nested child's form.
*
* @returns {HTMLFormElement|undefined}
*/
get form() {
return this.querySelector("form.element-body")
return this.querySelector(`#${this.id} > form.element-body`)
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/javascript/alchemy_admin/components/preview_window.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ class PreviewWindow extends HTMLIFrameElement {
if (event.data.message === "Alchemy.previewReady") {
this.#clearLoadTimeout()
this.#stopSpinner()
this.#afterLoad?.call(this, event)
// Deliberately does not resolve #afterLoad: previewReady fires before the
// images are loaded, so the page is not yet tall enough to scroll and a
// scrollIntoView triggered now (e.g. focusing the saved element) is a
// no-op. The load event handler resolves it once the layout is final.
}
}

Expand Down
1 change: 0 additions & 1 deletion app/views/alchemy/admin/elements/_footer.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
<button
form="element_<%= element.id %>_form"
is="alchemy-button"
data-turbo="false"
type="submit"
<%= "disabled" if cannot?(:update, element) %>
>
Expand Down
4 changes: 1 addition & 3 deletions app/views/alchemy/admin/elements/_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
<span class="element-title">
<span class="preview_text_element_name"><%= element.display_name %></span>
<%= render_hint_for(element, size: "1x", fixed_width: false) %>
<span class="preview_text_quote">
<%= sanitize(element.preview_text.presence || '&nbsp;') %>
</span>
<%= render "alchemy/admin/elements/preview_text_quote", element: element %>
</span>
<span class="element-status-icons">
<% unless element.public? || element.scheduled? %>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<span id="element_<%= element.id %>_preview_text_quote" class="preview_text_quote">
<%= sanitize(element.preview_text.presence || '&nbsp;') %>
</span>
31 changes: 31 additions & 0 deletions app/views/alchemy/admin/elements/update.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<% unless @element_validated %>
<alchemy-growl type="warn"><%= @warning %></alchemy-growl>

<% @element.ingredients_with_errors.each do |ingredient| %>
<%= turbo_stream.replace ingredient do %>
<%= render Alchemy::Admin::IngredientEditor.new(ingredient: ingredient) %>
<% end %>
<% end %>
<% else %>
<alchemy-growl><%= Alchemy.t(:element_saved) %></alchemy-growl>

<%# Re-render the preview text quote of the element and all its ancestors,
whose quotes are derived from their nested elements' preview text. %>
<% el = @element %>
<% while el %>
<%= turbo_stream.replace "element_#{el.id}_preview_text_quote" do %>
<%= render "alchemy/admin/elements/preview_text_quote", element: el %>
<% end %>
<% el = el.parent_element %>
<% end %>

<%= turbo_stream.replace "publish_page_button" do %>
<%= render Alchemy::Admin::PublishPageButton.new(page: @element.page) %>
<% end %>

<% @element.ingredients.each do |ingredient| %>
<% if ingredient.settings[:anchor] %>
<alchemy-action name="updateAnchorIcon" params="<%= [ingredient.id, ingredient.dom_id.present?].to_json %>"></alchemy-action>
<% end %>
<% end %>
<% end %>
26 changes: 26 additions & 0 deletions spec/components/alchemy/ingredients/base_editor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,32 @@

it { is_expected.to have_selector(".with-anchor") }
end

context "when the ingredient has validation errors" do
before do
ingredient.errors.add(:value, "can't be blank")
end

it { is_expected.to have_selector(".validation_failed") }
end
end

describe "validation errors" do
context "when the ingredient has errors on value" do
before do
ingredient.errors.add(:value, "can't be blank")
end

it "renders an inline error message" do
is_expected.to have_selector("small.error", text: "can't be blank")
end
end

context "when the ingredient has no errors" do
it "renders no inline error message" do
is_expected.to have_no_selector("small.error")
end
end
end

describe "data attributes" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ module Alchemy

context "with element having ingredients" do
subject do
put :update, params: {id: element.id, element: element_params}, xhr: true
put :update, params: {id: element.id, element: element_params}, format: :turbo_stream
end

let(:element) { create(:alchemy_element, :with_ingredients) }
Expand Down
34 changes: 34 additions & 0 deletions spec/features/admin/edit_elements_feature_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,31 @@
expect(page).to have_content(/Saved element/)
end
end

scenario "updates the preview text in the element header" do
visit alchemy.admin_elements_path(page_version_id: element.page_version_id)
fill_in "Intro", with: "My intro text"
within ".element-footer" do
click_button("Save")
end
within ".element-header" do
expect(page).to have_content("My intro text")
end
end

scenario "marks the page as having unpublished changes" do
Alchemy::Page::Publisher.new(a_page).publish!(public_on: Time.current)
a_page.reload
a_page.draft_version.update_column(:updated_at, 1.hour.ago)
a_page.public_version.update_column(:updated_at, 1.minute.ago)

visit alchemy.edit_admin_page_path(a_page)
expect(page).to have_css("#publish_page_button sl-button[disabled]")
within ".element-footer" do
click_button("Save")
end
expect(page).to have_css("#publish_page_button sl-button:not([disabled])")
end
end

context "with invalid data" do
Expand All @@ -184,6 +209,15 @@
expect(page).to have_content(/Please check marked fields below/)
end
end

scenario "marks the failing ingredient with an inline error" do
visit alchemy.admin_elements_path(page_version_id: element.page_version_id)
fill_in "Headline", with: "123"
within ".element-footer" do
click_button("Save")
end
expect(page).to have_css(".ingredient-editor.validation_failed small.error")
end
end
end

Expand Down
Loading
Loading