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/admin.css

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions app/components/alchemy/admin/ingredient_editor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module Alchemy
module Admin
# Adapter component for rendering ingredient editors.
#
# Handles both deprecated partial-based editors and component based editors.
# Use with_collection for efficient batch rendering of ingredients.
#
# @example Component based editors (no element_form needed)
# <%= render Alchemy::Admin::IngredientEditor.with_collection(
# element.ungrouped_ingredients
# ) %>
#
# @example With element_form for deprecated partials
# <%= render Alchemy::Admin::IngredientEditor.with_collection(
# element.ungrouped_ingredients,
# element_form: f
# ) %>
#
class IngredientEditor < ViewComponent::Base
with_collection_parameter :ingredient

# @param ingredient [Alchemy::Ingredient] The ingredient to render an editor for
# @param element_form [ActionView::Helpers::FormBuilder, nil] Optional form builder for deprecated partials
def initialize(ingredient:, element_form: nil)
@ingredient = ingredient
@element_form = element_form
end

def call
if has_editor_partial?
deprecation_notice
render partial: "alchemy/ingredients/#{@ingredient.partial_name}_editor",
locals: {element_form: @element_form},
object: @ingredient
else
render @ingredient.as_editor_component
end
end

private

def has_editor_partial?
helpers.lookup_context.template_exists?("alchemy/ingredients/_#{@ingredient.partial_name}_editor")
end

def deprecation_notice
Alchemy::Deprecation.warn <<~WARN
Ingredient editor partials are deprecated!
Please create a `#{@ingredient.class.name}Editor` class inheriting from `Alchemy::Ingredients::BaseEditor`.
WARN
end
end
end
end
8 changes: 8 additions & 0 deletions app/components/alchemy/ingredients/audio_editor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Alchemy
module Ingredients
class AudioEditor < FileEditor
end
end
end
216 changes: 216 additions & 0 deletions app/components/alchemy/ingredients/base_editor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# frozen_string_literal: true

module Alchemy
module Ingredients
class BaseEditor < ViewComponent::Base
delegate :definition,
:element,
:id,
:linked?,
:partial_name,
:role,
:settings,
:value,
to: :ingredient

delegate :alchemy,
:dom_id,
:hint_with_tooltip,
:render_hint_for,
:render_icon,
:warning,
to: :helpers

attr_reader :ingredient, :html_options

def initialize(ingredient, html_options: {})
raise ArgumentError, "Ingredient missing!" if ingredient.nil?

@ingredient = ingredient
@html_options = html_options
end

def call
tag.div(class: css_classes, data: data_attributes, id: dom_id(ingredient)) do
concat ingredient_id_field
concat ingredient_label
concat input_field
end
end

# Returns a string to be passed to Rails form field helpers.
#
# === Example:
#
# <%= text_field_tag text_editor.form_field_name, text_editor.value %>
#
# === Options:
#
# You can pass an Ingredient column_name. Default is 'value'
#
# ==== Example:
#
# <%= text_field_tag text_editor.form_field_name(:link), text_editor.value %>
#
def form_field_name(column = "value")
"element[ingredients_attributes][#{form_field_counter}][#{column}]"
end

# Returns a unique string to be passed to a form field id.
#
# @param column [String] A Ingredient column_name. Default is 'value'
#
def form_field_id(column = "value")
"element_#{element.id}_ingredient_#{ingredient.id}_#{column}"
end

private

# Returns the translated role for displaying in labels
#
# Translate it in your locale yml file:
#
# alchemy:
# ingredient_roles:
# foo: Bar
#
# Optionally you can scope your ingredient role to an element:
#
# alchemy:
# ingredient_roles:
# article:
# foo: Baz
#
def translated_role
Alchemy.t(
role,
scope: "ingredient_roles.#{element.name}",
default: Alchemy.t("ingredient_roles.#{role}", default: role.humanize)
)
end

def css_classes
[
"ingredient-editor",
partial_name,
ingredient.deprecated? ? "deprecated" : nil,
settings[:linkable] ? "linkable" : nil,
settings[:anchor] ? "with-anchor" : nil
].compact
end

def data_attributes
{
ingredient_id: ingredient.id,
ingredient_role: role
}
end

def has_warnings?
definition.blank? || ingredient.deprecated?
end

def warnings
return unless has_warnings?

if definition.blank?
Logger.warn("ingredient #{role} is missing its definition", caller(1..1))
Alchemy.t(:ingredient_definition_missing)
else
definition.deprecation_notice(element_name: element&.name)
end
end

def validations
definition.validate
end

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

# If format is a string or symbol, resolve it from config format_matchers
if format.is_a?(String) || format.is_a?(Symbol)
Alchemy.config.format_matchers.get(format)
else
format
end
end

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

def presence_validation?
validations.any? do |validation|
case validation
when :presence, "presence"
true
when Hash
validation[:presence] == true || validation["presence"] == true
else
false
end
end
end

def form_field_counter
element.definition.ingredients.index { |i| i.role == role }
end

# Renders the translated role of ingredient.
#
# Displays a warning icon if ingredient is missing its definition.
#
# Displays a mandatory field indicator, if the ingredient has validations.
#
def ingredient_role
content = translated_role

if has_warnings?
icon = hint_with_tooltip(warnings)
content = "#{icon} #{content}".html_safe
end

if ingredient.has_validations?
"#{content}<span class='validation_indicator'>*</span>".html_safe
else
content
end
end

# Renders the label and hint for a ingredient.
def ingredient_label(column = :value)
label_tag form_field_id(column) do
concat ingredient_role
concat render_hint_for(ingredient, size: "1x", fixed_width: false)
end
end

# Renders a hidden field with the ingredient's ID.
# This allows Rails to identify which ingredient to update
# when processing nested attributes.
#
def ingredient_id_field
hidden_field_tag(form_field_name(:id), ingredient.id, id: nil)
end

# Renders the input field for the ingredient.
# Override this method in subclasses to provide custom input fields.
# For example a text area or a select box.
#
def input_field
tag.div(class: "input-field") do
text_field_tag(form_field_name,
value,
class: "full_width",
id: form_field_id,
minlength: length_validation&.fetch(:minimum, nil),
maxlength: length_validation&.fetch(:maximum, nil),
required: presence_validation?,
pattern: format_validation)
end
end
end
end
end
21 changes: 21 additions & 0 deletions app/components/alchemy/ingredients/boolean_editor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Alchemy
module Ingredients
class BooleanEditor < BaseEditor
def call
tag.div(class: css_classes, data: data_attributes, id: dom_id(ingredient)) do
concat ingredient_id_field
concat label_tag(nil, for: form_field_id) {
safe_join([
hidden_field_tag(form_field_name, "0", id: nil),
check_box_tag(form_field_name, "1", value, id: form_field_id),
ingredient_role,
render_hint_for(ingredient, size: "1x", fixed_width: false)
])
}
end
end
end
end
end
27 changes: 27 additions & 0 deletions app/components/alchemy/ingredients/datetime_editor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Alchemy
module Ingredients
class DatetimeEditor < BaseEditor
delegate :alchemy_datepicker, to: :helpers

def input_field
tag.div(class: "input-field") do
concat alchemy_datepicker(
ingredient, :value, {
name: form_field_name,
id: form_field_id,
value: value,
type: settings[:input_type]
}
)
concat tag.label(
render_icon(:calendar),
for: form_field_id,
class: "ingredient-date--label"
)
end
end
end
end
end
Loading
Loading