Skip to content

Commit 8e4cf6e

Browse files
authored
Merge pull request #3457 from AlchemyCMS/ingredient-editor-view-components
Add ingredient editor components
2 parents a0ca3f9 + d1c2bad commit 8e4cf6e

File tree

79 files changed

+2225
-798
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+2225
-798
lines changed

app/assets/builds/alchemy/admin.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
module Admin
5+
# Adapter component for rendering ingredient editors.
6+
#
7+
# Handles both deprecated partial-based editors and component based editors.
8+
# Use with_collection for efficient batch rendering of ingredients.
9+
#
10+
# @example Component based editors (no element_form needed)
11+
# <%= render Alchemy::Admin::IngredientEditor.with_collection(
12+
# element.ungrouped_ingredients
13+
# ) %>
14+
#
15+
# @example With element_form for deprecated partials
16+
# <%= render Alchemy::Admin::IngredientEditor.with_collection(
17+
# element.ungrouped_ingredients,
18+
# element_form: f
19+
# ) %>
20+
#
21+
class IngredientEditor < ViewComponent::Base
22+
with_collection_parameter :ingredient
23+
24+
# @param ingredient [Alchemy::Ingredient] The ingredient to render an editor for
25+
# @param element_form [ActionView::Helpers::FormBuilder, nil] Optional form builder for deprecated partials
26+
def initialize(ingredient:, element_form: nil)
27+
@ingredient = ingredient
28+
@element_form = element_form
29+
end
30+
31+
def call
32+
if has_editor_partial?
33+
deprecation_notice
34+
render partial: "alchemy/ingredients/#{@ingredient.partial_name}_editor",
35+
locals: {element_form: @element_form},
36+
object: @ingredient
37+
else
38+
render @ingredient.as_editor_component
39+
end
40+
end
41+
42+
private
43+
44+
def has_editor_partial?
45+
helpers.lookup_context.template_exists?("alchemy/ingredients/_#{@ingredient.partial_name}_editor")
46+
end
47+
48+
def deprecation_notice
49+
Alchemy::Deprecation.warn <<~WARN
50+
Ingredient editor partials are deprecated!
51+
Please create a `#{@ingredient.class.name}Editor` class inheriting from `Alchemy::Ingredients::BaseEditor`.
52+
WARN
53+
end
54+
end
55+
end
56+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
module Ingredients
5+
class AudioEditor < FileEditor
6+
end
7+
end
8+
end
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
module Ingredients
5+
class BaseEditor < ViewComponent::Base
6+
delegate :definition,
7+
:element,
8+
:id,
9+
:linked?,
10+
:partial_name,
11+
:role,
12+
:settings,
13+
:value,
14+
to: :ingredient
15+
16+
delegate :alchemy,
17+
:dom_id,
18+
:hint_with_tooltip,
19+
:render_hint_for,
20+
:render_icon,
21+
:warning,
22+
to: :helpers
23+
24+
attr_reader :ingredient, :html_options
25+
26+
def initialize(ingredient, html_options: {})
27+
raise ArgumentError, "Ingredient missing!" if ingredient.nil?
28+
29+
@ingredient = ingredient
30+
@html_options = html_options
31+
end
32+
33+
def call
34+
tag.div(class: css_classes, data: data_attributes, id: dom_id(ingredient)) do
35+
concat ingredient_id_field
36+
concat ingredient_label
37+
concat input_field
38+
end
39+
end
40+
41+
# Returns a string to be passed to Rails form field helpers.
42+
#
43+
# === Example:
44+
#
45+
# <%= text_field_tag text_editor.form_field_name, text_editor.value %>
46+
#
47+
# === Options:
48+
#
49+
# You can pass an Ingredient column_name. Default is 'value'
50+
#
51+
# ==== Example:
52+
#
53+
# <%= text_field_tag text_editor.form_field_name(:link), text_editor.value %>
54+
#
55+
def form_field_name(column = "value")
56+
"element[ingredients_attributes][#{form_field_counter}][#{column}]"
57+
end
58+
59+
# Returns a unique string to be passed to a form field id.
60+
#
61+
# @param column [String] A Ingredient column_name. Default is 'value'
62+
#
63+
def form_field_id(column = "value")
64+
"element_#{element.id}_ingredient_#{ingredient.id}_#{column}"
65+
end
66+
67+
private
68+
69+
# Returns the translated role for displaying in labels
70+
#
71+
# Translate it in your locale yml file:
72+
#
73+
# alchemy:
74+
# ingredient_roles:
75+
# foo: Bar
76+
#
77+
# Optionally you can scope your ingredient role to an element:
78+
#
79+
# alchemy:
80+
# ingredient_roles:
81+
# article:
82+
# foo: Baz
83+
#
84+
def translated_role
85+
Alchemy.t(
86+
role,
87+
scope: "ingredient_roles.#{element.name}",
88+
default: Alchemy.t("ingredient_roles.#{role}", default: role.humanize)
89+
)
90+
end
91+
92+
def css_classes
93+
[
94+
"ingredient-editor",
95+
partial_name,
96+
ingredient.deprecated? ? "deprecated" : nil,
97+
settings[:linkable] ? "linkable" : nil,
98+
settings[:anchor] ? "with-anchor" : nil
99+
].compact
100+
end
101+
102+
def data_attributes
103+
{
104+
ingredient_id: ingredient.id,
105+
ingredient_role: role
106+
}
107+
end
108+
109+
def has_warnings?
110+
definition.blank? || ingredient.deprecated?
111+
end
112+
113+
def warnings
114+
return unless has_warnings?
115+
116+
if definition.blank?
117+
Logger.warn("ingredient #{role} is missing its definition", caller(1..1))
118+
Alchemy.t(:ingredient_definition_missing)
119+
else
120+
definition.deprecation_notice(element_name: element&.name)
121+
end
122+
end
123+
124+
def validations
125+
definition.validate
126+
end
127+
128+
def format_validation
129+
format = validations.select { _1.is_a?(Hash) }.find { _1[:format] }&.fetch(:format)
130+
return nil unless format
131+
132+
# If format is a string or symbol, resolve it from config format_matchers
133+
if format.is_a?(String) || format.is_a?(Symbol)
134+
Alchemy.config.format_matchers.get(format)
135+
else
136+
format
137+
end
138+
end
139+
140+
def length_validation
141+
validations.select { _1.is_a?(Hash) }.find { _1[:length] }&.fetch(:length)
142+
end
143+
144+
def presence_validation?
145+
validations.any? do |validation|
146+
case validation
147+
when :presence, "presence"
148+
true
149+
when Hash
150+
validation[:presence] == true || validation["presence"] == true
151+
else
152+
false
153+
end
154+
end
155+
end
156+
157+
def form_field_counter
158+
element.definition.ingredients.index { |i| i.role == role }
159+
end
160+
161+
# Renders the translated role of ingredient.
162+
#
163+
# Displays a warning icon if ingredient is missing its definition.
164+
#
165+
# Displays a mandatory field indicator, if the ingredient has validations.
166+
#
167+
def ingredient_role
168+
content = translated_role
169+
170+
if has_warnings?
171+
icon = hint_with_tooltip(warnings)
172+
content = "#{icon} #{content}".html_safe
173+
end
174+
175+
if ingredient.has_validations?
176+
"#{content}<span class='validation_indicator'>*</span>".html_safe
177+
else
178+
content
179+
end
180+
end
181+
182+
# Renders the label and hint for a ingredient.
183+
def ingredient_label(column = :value)
184+
label_tag form_field_id(column) do
185+
concat ingredient_role
186+
concat render_hint_for(ingredient, size: "1x", fixed_width: false)
187+
end
188+
end
189+
190+
# Renders a hidden field with the ingredient's ID.
191+
# This allows Rails to identify which ingredient to update
192+
# when processing nested attributes.
193+
#
194+
def ingredient_id_field
195+
hidden_field_tag(form_field_name(:id), ingredient.id, id: nil)
196+
end
197+
198+
# Renders the input field for the ingredient.
199+
# Override this method in subclasses to provide custom input fields.
200+
# For example a text area or a select box.
201+
#
202+
def input_field
203+
tag.div(class: "input-field") do
204+
text_field_tag(form_field_name,
205+
value,
206+
class: "full_width",
207+
id: form_field_id,
208+
minlength: length_validation&.fetch(:minimum, nil),
209+
maxlength: length_validation&.fetch(:maximum, nil),
210+
required: presence_validation?,
211+
pattern: format_validation)
212+
end
213+
end
214+
end
215+
end
216+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
module Ingredients
5+
class BooleanEditor < BaseEditor
6+
def call
7+
tag.div(class: css_classes, data: data_attributes, id: dom_id(ingredient)) do
8+
concat ingredient_id_field
9+
concat label_tag(nil, for: form_field_id) {
10+
safe_join([
11+
hidden_field_tag(form_field_name, "0", id: nil),
12+
check_box_tag(form_field_name, "1", value, id: form_field_id),
13+
ingredient_role,
14+
render_hint_for(ingredient, size: "1x", fixed_width: false)
15+
])
16+
}
17+
end
18+
end
19+
end
20+
end
21+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
module Ingredients
5+
class DatetimeEditor < BaseEditor
6+
delegate :alchemy_datepicker, to: :helpers
7+
8+
def input_field
9+
tag.div(class: "input-field") do
10+
concat alchemy_datepicker(
11+
ingredient, :value, {
12+
name: form_field_name,
13+
id: form_field_id,
14+
value: value,
15+
type: settings[:input_type]
16+
}
17+
)
18+
concat tag.label(
19+
render_icon(:calendar),
20+
for: form_field_id,
21+
class: "ingredient-date--label"
22+
)
23+
end
24+
end
25+
end
26+
end
27+
end

0 commit comments

Comments
 (0)