typed_eav 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +21 -0
- data/README.md +494 -0
- data/Rakefile +13 -0
- data/app/models/typed_eav/application_record.rb +7 -0
- data/app/models/typed_eav/field/base.rb +234 -0
- data/app/models/typed_eav/field/boolean.rb +22 -0
- data/app/models/typed_eav/field/color.rb +16 -0
- data/app/models/typed_eav/field/date.rb +24 -0
- data/app/models/typed_eav/field/date_array.rb +34 -0
- data/app/models/typed_eav/field/date_time.rb +29 -0
- data/app/models/typed_eav/field/decimal.rb +30 -0
- data/app/models/typed_eav/field/decimal_array.rb +31 -0
- data/app/models/typed_eav/field/email.rb +40 -0
- data/app/models/typed_eav/field/integer.rb +30 -0
- data/app/models/typed_eav/field/integer_array.rb +68 -0
- data/app/models/typed_eav/field/json.rb +26 -0
- data/app/models/typed_eav/field/long_text.rb +19 -0
- data/app/models/typed_eav/field/multi_select.rb +41 -0
- data/app/models/typed_eav/field/select.rb +28 -0
- data/app/models/typed_eav/field/text.rb +41 -0
- data/app/models/typed_eav/field/text_array.rb +36 -0
- data/app/models/typed_eav/field/url.rb +40 -0
- data/app/models/typed_eav/option.rb +24 -0
- data/app/models/typed_eav/section.rb +25 -0
- data/app/models/typed_eav/value.rb +149 -0
- data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
- data/lib/generators/typed_eav/install/install_generator.rb +28 -0
- data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
- data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
- data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
- data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
- data/lib/typed_eav/column_mapping.rb +64 -0
- data/lib/typed_eav/config.rb +91 -0
- data/lib/typed_eav/engine.rb +20 -0
- data/lib/typed_eav/has_typed_eav.rb +484 -0
- data/lib/typed_eav/query_builder.rb +133 -0
- data/lib/typed_eav/registry.rb +52 -0
- data/lib/typed_eav/version.rb +5 -0
- data/lib/typed_eav.rb +86 -0
- metadata +146 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class TypedEAVController < ApplicationController
|
|
4
|
+
include TypedEAVControllerConcern
|
|
5
|
+
|
|
6
|
+
# Fail-closed authorization. The generated admin manages field DEFINITIONS
|
|
7
|
+
# (schema-like data visible across entity types and tenants), so we pretend
|
|
8
|
+
# these routes don't exist unless the host app explicitly grants access.
|
|
9
|
+
# `head :not_found` is intentional — a 403 leaks that these routes exist.
|
|
10
|
+
#
|
|
11
|
+
# TO ENABLE ACCESS, edit the `authorize_typed_eav_admin!` method below
|
|
12
|
+
# directly in this file (defining a same-named method in ApplicationController
|
|
13
|
+
# does NOT override it — Ruby looks up methods on the subclass first).
|
|
14
|
+
# Examples to paste into the private method:
|
|
15
|
+
#
|
|
16
|
+
# # Pundit
|
|
17
|
+
# def authorize_typed_eav_admin!
|
|
18
|
+
# authorize :typed_eav, :manage?
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # CanCanCan
|
|
22
|
+
# def authorize_typed_eav_admin!
|
|
23
|
+
# authorize! :manage, TypedEAV::Field::Base
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # Host-app admin predicate
|
|
27
|
+
# def authorize_typed_eav_admin!
|
|
28
|
+
# head :not_found unless current_user&.admin?
|
|
29
|
+
# end
|
|
30
|
+
before_action :authorize_typed_eav_admin!
|
|
31
|
+
before_action :set_field, only: %i[show edit update destroy]
|
|
32
|
+
|
|
33
|
+
def index
|
|
34
|
+
@fields = scoped_fields.order(:entity_type, :scope, :sort_order, :name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def show; end
|
|
38
|
+
|
|
39
|
+
def new
|
|
40
|
+
type_class = resolve_type_class(params[:type])
|
|
41
|
+
@field = type_class.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def edit; end
|
|
45
|
+
|
|
46
|
+
def create
|
|
47
|
+
type_class = resolve_type_class(params.dig(:typed_eav_field, :field_type) || params[:type])
|
|
48
|
+
ambient = ensure_scope!
|
|
49
|
+
attrs = field_params(type_class, creating: true)
|
|
50
|
+
attrs[:scope] = ambient
|
|
51
|
+
attrs[:section_id] = verified_section_id(
|
|
52
|
+
params.dig(:typed_eav_field, :section_id),
|
|
53
|
+
attrs[:entity_type],
|
|
54
|
+
ambient
|
|
55
|
+
)
|
|
56
|
+
@field = type_class.new(attrs)
|
|
57
|
+
|
|
58
|
+
if @field.save
|
|
59
|
+
redirect_to edit_typed_eav_field_path(@field), status: :see_other,
|
|
60
|
+
notice: "Field created."
|
|
61
|
+
else
|
|
62
|
+
render :new, status: :unprocessable_content
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def update
|
|
67
|
+
attrs = field_params(@field.class, creating: false)
|
|
68
|
+
attrs[:section_id] = verified_section_id(
|
|
69
|
+
params.dig(:typed_eav_field, :section_id),
|
|
70
|
+
@field.entity_type,
|
|
71
|
+
@field.scope
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if @field.update(attrs)
|
|
75
|
+
redirect_to edit_typed_eav_field_path(@field), status: :see_other,
|
|
76
|
+
notice: "Field updated."
|
|
77
|
+
else
|
|
78
|
+
render :edit, status: :unprocessable_content
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def destroy
|
|
83
|
+
@field.destroy!
|
|
84
|
+
redirect_to typed_eav_fields_path, status: :see_other, notice: "Field deleted."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# POST /typed_eav_fields/:typed_eav_field_id/field_options/add_option
|
|
88
|
+
def add_option
|
|
89
|
+
@field = scoped_fields.find(params[:typed_eav_field_id])
|
|
90
|
+
# Lock the field row during option creation so two concurrent creates
|
|
91
|
+
# can't observe the same `count` and assign the same sort_order.
|
|
92
|
+
@field.with_lock do
|
|
93
|
+
next_order = (@field.field_options.maximum(:sort_order) || 0) + 1
|
|
94
|
+
@field.field_options.create!(
|
|
95
|
+
label: params[:option_label],
|
|
96
|
+
value: params[:option_value],
|
|
97
|
+
sort_order: next_order
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
redirect_to edit_typed_eav_field_path(@field), status: :see_other
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# DELETE /typed_eav_fields/:typed_eav_field_id/field_options/remove_option
|
|
104
|
+
def remove_option
|
|
105
|
+
@field = scoped_fields.find(params[:typed_eav_field_id])
|
|
106
|
+
@field.field_options.find(params[:option_id]).destroy!
|
|
107
|
+
redirect_to edit_typed_eav_field_path(@field), status: :see_other
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Default: block all access. Host app must override to grant access.
|
|
113
|
+
# `head :not_found` is intentional — a 403 leaks that these admin
|
|
114
|
+
# routes exist.
|
|
115
|
+
def authorize_typed_eav_admin!
|
|
116
|
+
head :not_found
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def set_field
|
|
120
|
+
@field = scoped_fields.find(params[:id])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Base relation filtered to the current ambient scope. Fields with
|
|
124
|
+
# scope=NULL (global fields visible to all partitions) are always
|
|
125
|
+
# included. Fail-closed semantics:
|
|
126
|
+
# - unscoped? -> ALL fields across every scope (admin override)
|
|
127
|
+
# - scope present -> fields for that scope UNION globals
|
|
128
|
+
# - scope nil, require_scope=true -> raise TypedEAV::ScopeRequired
|
|
129
|
+
# - scope nil, require_scope=false -> globals only (scope=NULL); never
|
|
130
|
+
# leaks other tenants' rows.
|
|
131
|
+
# Use `TypedEAV.with_scope(value) { }` or configure
|
|
132
|
+
# `TypedEAV.config.scope_resolver` to set the scope.
|
|
133
|
+
def scoped_fields
|
|
134
|
+
# Inside `TypedEAV.unscoped { }` the caller has explicitly opted into
|
|
135
|
+
# cross-scope visibility (admin/migration paths). Skip the scope filter
|
|
136
|
+
# entirely so the scaffold remains usable instead of raising under
|
|
137
|
+
# require_scope=true.
|
|
138
|
+
return TypedEAV::Field::Base.all if TypedEAV.unscoped?
|
|
139
|
+
|
|
140
|
+
scope = TypedEAV.current_scope
|
|
141
|
+
return TypedEAV::Field::Base.where(scope: [scope, nil]) if scope
|
|
142
|
+
|
|
143
|
+
if TypedEAV.config.require_scope
|
|
144
|
+
raise TypedEAV::ScopeRequired,
|
|
145
|
+
"TypedEAV.current_scope is nil and require_scope is enabled; " \
|
|
146
|
+
"wrap the request in TypedEAV.with_scope(value) { } or configure " \
|
|
147
|
+
"TypedEAV.config.scope_resolver."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
TypedEAV::Field::Base.where(scope: nil)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Resolve the ambient scope for writes. Mirrors `scoped_fields` semantics:
|
|
154
|
+
# - unscoped? -> returns nil (admin override; new field is global)
|
|
155
|
+
# - scope present -> returns the scope value
|
|
156
|
+
# - scope nil, require_scope=true -> raises TypedEAV::ScopeRequired
|
|
157
|
+
# - scope nil, require_scope=false -> returns nil (global-field creation)
|
|
158
|
+
def ensure_scope!
|
|
159
|
+
# Inside `TypedEAV.unscoped { }` we deliberately bypass the require_scope
|
|
160
|
+
# guard so admin tools can create global fields without first declaring an
|
|
161
|
+
# ambient scope. Wrap in `with_scope` to write into a specific tenant.
|
|
162
|
+
return nil if TypedEAV.unscoped?
|
|
163
|
+
|
|
164
|
+
scope = TypedEAV.current_scope
|
|
165
|
+
return scope if scope
|
|
166
|
+
|
|
167
|
+
if TypedEAV.config.require_scope
|
|
168
|
+
raise TypedEAV::ScopeRequired,
|
|
169
|
+
"TypedEAV.current_scope is nil and require_scope is enabled; " \
|
|
170
|
+
"wrap the request in TypedEAV.with_scope(value) { } or configure " \
|
|
171
|
+
"TypedEAV.config.scope_resolver."
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Server-side verification that the requested section exists within the
|
|
178
|
+
# caller's entity_type + scope (or globals). `Section.for_entity` unions
|
|
179
|
+
# scope=NULL globals with the scoped rows, matching field visibility.
|
|
180
|
+
# Returns nil if `id` is blank. Raises ActiveRecord::RecordNotFound (Rails
|
|
181
|
+
# renders 404) if the id does not belong to a section the caller can see,
|
|
182
|
+
# blocking cross-tenant assignment via a forged section_id.
|
|
183
|
+
def verified_section_id(id, entity_type, scope)
|
|
184
|
+
return nil if id.blank?
|
|
185
|
+
TypedEAV::Section.for_entity(entity_type, scope: scope).find(id).id
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def resolve_type_class(type_name)
|
|
189
|
+
return TypedEAV::Field::Text if type_name.blank?
|
|
190
|
+
TypedEAV.config.field_class_for(type_name)
|
|
191
|
+
rescue ArgumentError
|
|
192
|
+
TypedEAV::Field::Text
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Data-driven permitted params based on what the field type exposes via
|
|
196
|
+
# store_accessor. Much cleaner than a massive case statement per type.
|
|
197
|
+
#
|
|
198
|
+
# NOTE: `scope` and `section_id` are intentionally NOT in the permit list.
|
|
199
|
+
# `scope` is derived server-side from the ambient scope in `create`; a
|
|
200
|
+
# client-supplied value would let any authenticated user write into another
|
|
201
|
+
# tenant's partition. `section_id` is verified against a scoped Section
|
|
202
|
+
# lookup via `verified_section_id` on both create and update.
|
|
203
|
+
def field_params(type_class, creating:)
|
|
204
|
+
base = %i[name required sort_order]
|
|
205
|
+
base += %i[entity_type] if creating
|
|
206
|
+
|
|
207
|
+
# Collect store_accessor keys from options (min, max, min_length, etc.)
|
|
208
|
+
option_keys = option_keys_for(type_class)
|
|
209
|
+
|
|
210
|
+
# Default value is scalar for most types, array for array types
|
|
211
|
+
if type_class.method_defined?(:array_field?) && type_class.allocate.array_field?
|
|
212
|
+
permitted = base + option_keys + [default_value: []]
|
|
213
|
+
else
|
|
214
|
+
permitted = base + option_keys + %i[default_value]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
params.require(:typed_eav_field).permit(*permitted).tap do |attrs|
|
|
218
|
+
attrs.transform_values! do |value|
|
|
219
|
+
value.is_a?(Array) ? compact_array_param(value) : value
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Introspect which option keys the field type exposes
|
|
225
|
+
def option_keys_for(type_class)
|
|
226
|
+
return [] unless type_class.respond_to?(:stored_attributes)
|
|
227
|
+
(type_class.stored_attributes[:options] || []).map(&:to_sym)
|
|
228
|
+
rescue StandardError
|
|
229
|
+
[]
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAVHelper
|
|
4
|
+
# ─── Value Input Rendering ───────────────────────────────────
|
|
5
|
+
|
|
6
|
+
# Render all typed field inputs for a record's form. Owns the
|
|
7
|
+
# `fields_for :typed_values` builder so the submitted params are
|
|
8
|
+
# shaped correctly for `accepts_nested_attributes_for :typed_values`.
|
|
9
|
+
#
|
|
10
|
+
# <%= render_typed_value_inputs(form: f, record: @contact) %>
|
|
11
|
+
#
|
|
12
|
+
# The controller's strong params must permit:
|
|
13
|
+
#
|
|
14
|
+
# typed_values_attributes: [:id, :field_id, :value, :_destroy,
|
|
15
|
+
# { value: [] }]
|
|
16
|
+
#
|
|
17
|
+
def render_typed_value_inputs(form:, record:)
|
|
18
|
+
# Index the in-scope definitions by id. Used both to look up sort_order
|
|
19
|
+
# without touching `v.field` (avoids per-value field load when typed_values
|
|
20
|
+
# was preloaded but `:field` was not) AND to thread the resolved field
|
|
21
|
+
# into `render_typed_value_input` so it doesn't re-trigger that lookup.
|
|
22
|
+
fields_by_id = record.typed_eav_definitions.index_by(&:id)
|
|
23
|
+
|
|
24
|
+
typed_values = record.initialize_typed_values.sort_by do |v|
|
|
25
|
+
# Newly-built values may have field_id=nil but carry an in-memory
|
|
26
|
+
# `field` object; fall back to that to avoid sorting them all to 0.
|
|
27
|
+
field = fields_by_id[v.field_id] || v.field
|
|
28
|
+
field&.sort_order || 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
parts = typed_values.map do |typed_value|
|
|
32
|
+
form.fields_for(:typed_values, typed_value, child_index: nested_child_index(typed_value)) do |vf|
|
|
33
|
+
render_typed_value_input(form: vf, typed_value: typed_value, fields_by_id: fields_by_id)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
safe_join(parts)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Render a single typed value input. Expects `form` to be a typed-value
|
|
40
|
+
# builder (from `fields_for :typed_values`) — it emits the hidden `id` /
|
|
41
|
+
# `field_id` inputs nested attributes need to resolve the row, then
|
|
42
|
+
# delegates to the type-specific partial for the value input itself.
|
|
43
|
+
#
|
|
44
|
+
# Advanced callers that own their own `fields_for` block can invoke this
|
|
45
|
+
# directly:
|
|
46
|
+
#
|
|
47
|
+
# <%= form.fields_for :typed_values, typed_value do |vf| %>
|
|
48
|
+
# <%= render_typed_value_input(form: vf, typed_value: vf.object) %>
|
|
49
|
+
# <% end %>
|
|
50
|
+
#
|
|
51
|
+
# Pass `fields_by_id:` (a {field_id => Field} map) when iterating many
|
|
52
|
+
# values to avoid triggering a per-value `typed_value.field` query in the
|
|
53
|
+
# association-loaded-but-`:field`-not-preloaded case.
|
|
54
|
+
def render_typed_value_input(form:, typed_value:, fields_by_id: nil)
|
|
55
|
+
field = (fields_by_id && fields_by_id[typed_value.field_id]) || typed_value.field
|
|
56
|
+
partial_name = value_input_partial(field)
|
|
57
|
+
|
|
58
|
+
hidden = "".html_safe
|
|
59
|
+
hidden << form.hidden_field(:id) if typed_value.persisted?
|
|
60
|
+
hidden << form.hidden_field(:field_id, value: field.id)
|
|
61
|
+
|
|
62
|
+
hidden + render(partial: partial_name, locals: {
|
|
63
|
+
form: form,
|
|
64
|
+
typed_value: typed_value,
|
|
65
|
+
field: field,
|
|
66
|
+
})
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Render an array field with add/remove buttons (Stimulus-powered).
|
|
70
|
+
#
|
|
71
|
+
# <%= render_array_field(form: f, name: :value, value: [1,2,3],
|
|
72
|
+
# field_method: :number_field, field_opts: { min: 0 }) %>
|
|
73
|
+
#
|
|
74
|
+
def render_array_field(form:, name:, value:, field_method:, field_opts: {})
|
|
75
|
+
render partial: "shared/array_field", locals: {
|
|
76
|
+
form: form,
|
|
77
|
+
name: name,
|
|
78
|
+
value: value,
|
|
79
|
+
field_method: field_method,
|
|
80
|
+
field_opts: field_opts,
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ─── Field Management Form Rendering ─────────────────────────
|
|
85
|
+
|
|
86
|
+
# Render the field definition form (for creating/editing field definitions).
|
|
87
|
+
def render_typed_eav_form(field:)
|
|
88
|
+
partial = field_form_partial(field)
|
|
89
|
+
render partial: partial, locals: { field: field }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ─── Search/Filter Form Rendering ───────────────────────────
|
|
93
|
+
|
|
94
|
+
# Render a search form for filtering entities by typed fields.
|
|
95
|
+
#
|
|
96
|
+
# <%= render_typed_eav_search(fields: Contact.typed_eav_definitions, url: contacts_path) %>
|
|
97
|
+
#
|
|
98
|
+
def render_typed_eav_search(fields:, url:)
|
|
99
|
+
render partial: "typed_eav/finders/form", locals: {
|
|
100
|
+
fields: fields,
|
|
101
|
+
url: url,
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ─── Operator Labels ────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def typed_eav_operator_label(operator)
|
|
108
|
+
{
|
|
109
|
+
eq: "equals",
|
|
110
|
+
not_eq: "does not equal",
|
|
111
|
+
gt: "greater than",
|
|
112
|
+
gteq: "greater than or equal",
|
|
113
|
+
lt: "less than",
|
|
114
|
+
lteq: "less than or equal",
|
|
115
|
+
between: "between",
|
|
116
|
+
contains: "contains",
|
|
117
|
+
not_contains: "does not contain",
|
|
118
|
+
starts_with: "starts with",
|
|
119
|
+
ends_with: "ends with",
|
|
120
|
+
any_eq: "includes",
|
|
121
|
+
all_eq: "includes all",
|
|
122
|
+
is_null: "is empty",
|
|
123
|
+
is_not_null: "is not empty",
|
|
124
|
+
}[operator.to_sym] || operator.to_s.humanize
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Distinct `child_index` for each nested value so Rails generates unique
|
|
130
|
+
# param names. Use the object id for new records (stable within a request),
|
|
131
|
+
# the record id for persisted rows.
|
|
132
|
+
def nested_child_index(typed_value)
|
|
133
|
+
typed_value.persisted? ? typed_value.id : "new_#{typed_value.object_id}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Resolve the value input partial for a field type.
|
|
137
|
+
# Falls back to a generic text input if no specific partial exists.
|
|
138
|
+
def value_input_partial(field)
|
|
139
|
+
type_key = field.field_type_name
|
|
140
|
+
partial = "typed_eav/values/inputs/#{type_key}"
|
|
141
|
+
lookup_context.exists?(partial, [], true) ? partial : "typed_eav/values/inputs/text"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Resolve the field definition form partial.
|
|
145
|
+
def field_form_partial(field)
|
|
146
|
+
type_key = field.field_type_name
|
|
147
|
+
partial = "typed_eav/forms/#{type_key}"
|
|
148
|
+
lookup_context.exists?(partial, [], true) ? partial : "typed_eav/forms/base"
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Manages dynamic add/remove of array field inputs.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// <div data-controller="array-field">
|
|
7
|
+
// <div data-array-field-target="template" style="display:none">
|
|
8
|
+
// <input type="text" name="values[]" value="" />
|
|
9
|
+
// <button data-action="click->array-field#remove">Remove</button>
|
|
10
|
+
// </div>
|
|
11
|
+
//
|
|
12
|
+
// <!-- Existing items rendered server-side -->
|
|
13
|
+
// <div data-array-field-target="item">
|
|
14
|
+
// <input type="text" name="values[]" value="existing" />
|
|
15
|
+
// <button data-action="click->array-field#remove">Remove</button>
|
|
16
|
+
// </div>
|
|
17
|
+
//
|
|
18
|
+
// <button data-action="click->array-field#add">Add</button>
|
|
19
|
+
// </div>
|
|
20
|
+
//
|
|
21
|
+
export default class extends Controller {
|
|
22
|
+
static targets = ["template", "item", "container"]
|
|
23
|
+
|
|
24
|
+
connect() {
|
|
25
|
+
if (this.hasTemplateTarget) {
|
|
26
|
+
this.templateTarget.style.display = "none"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
add(event) {
|
|
31
|
+
event.preventDefault()
|
|
32
|
+
|
|
33
|
+
if (!this.hasTemplateTarget) return
|
|
34
|
+
|
|
35
|
+
const clone = this.templateTarget.cloneNode(true)
|
|
36
|
+
|
|
37
|
+
// Remove the template target data attribute so it becomes a regular item
|
|
38
|
+
delete clone.dataset.arrayFieldTarget
|
|
39
|
+
clone.style.removeProperty("display")
|
|
40
|
+
|
|
41
|
+
// Clear input values in the clone
|
|
42
|
+
clone.querySelectorAll("input, select, textarea").forEach(input => {
|
|
43
|
+
if (input.type === "checkbox" || input.type === "radio") {
|
|
44
|
+
input.checked = false
|
|
45
|
+
} else {
|
|
46
|
+
input.value = ""
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Insert before the "Add" button
|
|
51
|
+
event.target.before(clone)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
remove(event) {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
|
|
57
|
+
// Walk up to the nearest item wrapper div
|
|
58
|
+
const item = event.target.closest("[data-array-field-target='item']") ||
|
|
59
|
+
event.target.parentElement
|
|
60
|
+
if (item) {
|
|
61
|
+
item.remove()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Manages the typed field definition form.
|
|
4
|
+
// Handles dynamic scope toggling and type-specific option visibility.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// <form data-controller="typed-eav-form">
|
|
8
|
+
// <input data-typed-eav-form-target="scopeInput" />
|
|
9
|
+
// <input type="checkbox" data-typed-eav-form-target="disableScopeCheckbox"
|
|
10
|
+
// data-action="change->typed-eav-form#toggleScope" />
|
|
11
|
+
// </form>
|
|
12
|
+
//
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static targets = ["scopeInput", "disableScopeCheckbox"]
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
if (this.hasDisableScopeCheckboxTarget && this.hasDisableScopeCheckboxTarget) {
|
|
18
|
+
this.toggleScope()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
toggleScope() {
|
|
23
|
+
if (!this.hasScopeInputTarget || !this.hasDisableScopeCheckboxTarget) return
|
|
24
|
+
|
|
25
|
+
const disabled = this.disableScopeCheckboxTarget.checked
|
|
26
|
+
this.scopeInputTarget.disabled = disabled
|
|
27
|
+
|
|
28
|
+
if (disabled) {
|
|
29
|
+
this.scopeInputTarget.value = ""
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<div data-controller="array-field">
|
|
2
|
+
<%# Hidden template for cloning new items %>
|
|
3
|
+
<div data-array-field-target="template" style="display: none;">
|
|
4
|
+
<%= form.public_send(field_method, name, { multiple: true, value: "", **field_opts }) %>
|
|
5
|
+
<% unless field_opts[:disabled] %>
|
|
6
|
+
<button type="button" data-action="click->array-field#remove">Remove</button>
|
|
7
|
+
<% end %>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<%# Existing values %>
|
|
11
|
+
<% Array(value).each do |value_element| %>
|
|
12
|
+
<div data-array-field-target="item">
|
|
13
|
+
<%= form.public_send(field_method, name, { multiple: true, value: value_element || "", **field_opts }) %>
|
|
14
|
+
<% unless field_opts[:disabled] %>
|
|
15
|
+
<button type="button" data-action="click->array-field#remove">Remove</button>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<% unless field_opts[:disabled] %>
|
|
21
|
+
<button type="button" data-action="click->array-field#add">Add</button>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<h1>Edit <%= @field.field_type_name.humanize %> Field: <%= @field.name %></h1>
|
|
2
|
+
|
|
3
|
+
<%= render_typed_eav_form(field: @field) %>
|
|
4
|
+
|
|
5
|
+
<% if @field.optionable? %>
|
|
6
|
+
<hr>
|
|
7
|
+
<h2>Options</h2>
|
|
8
|
+
|
|
9
|
+
<table>
|
|
10
|
+
<thead>
|
|
11
|
+
<tr><th>Label</th><th>Value</th><th>Order</th><th></th></tr>
|
|
12
|
+
</thead>
|
|
13
|
+
<tbody>
|
|
14
|
+
<% @field.field_options.sorted.each do |option| %>
|
|
15
|
+
<tr>
|
|
16
|
+
<td><%= option.label %></td>
|
|
17
|
+
<td><code><%= option.value %></code></td>
|
|
18
|
+
<td><%= option.sort_order %></td>
|
|
19
|
+
<td>
|
|
20
|
+
<%= button_to "Remove",
|
|
21
|
+
remove_option_typed_eav_field_field_options_path(@field, option_id: option.id),
|
|
22
|
+
method: :delete,
|
|
23
|
+
data: { turbo_confirm: "Remove this option?" } %>
|
|
24
|
+
</td>
|
|
25
|
+
</tr>
|
|
26
|
+
<% end %>
|
|
27
|
+
</tbody>
|
|
28
|
+
</table>
|
|
29
|
+
|
|
30
|
+
<%= form_with url: add_option_typed_eav_field_field_options_path(@field), method: :post do |f| %>
|
|
31
|
+
<div style="display: flex; gap: 0.5rem; align-items: end; margin-top: 0.5rem;">
|
|
32
|
+
<div>
|
|
33
|
+
<%= f.label :option_label, "Label" %>
|
|
34
|
+
<%= f.text_field :option_label, required: true %>
|
|
35
|
+
</div>
|
|
36
|
+
<div>
|
|
37
|
+
<%= f.label :option_value, "Value" %>
|
|
38
|
+
<%= f.text_field :option_value, required: true %>
|
|
39
|
+
</div>
|
|
40
|
+
<%= f.submit "Add Option" %>
|
|
41
|
+
</div>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<div style="margin-top: 1rem;">
|
|
46
|
+
<%= link_to "Back", typed_eav_fields_path %>
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<%# Search/filter form for querying entities by typed field values.
|
|
2
|
+
Usage: render_typed_eav_search(fields: Contact.typed_eav_definitions, url: contacts_path) %>
|
|
3
|
+
|
|
4
|
+
<%= form_with url: url, method: :get, data: { turbo_frame: "_top" } do |f| %>
|
|
5
|
+
<fieldset>
|
|
6
|
+
<legend>Filter by Custom Fields</legend>
|
|
7
|
+
|
|
8
|
+
<div id="typed-eav-filters">
|
|
9
|
+
<% typed_eav_filter_params.each_with_index do |filter_params, idx| %>
|
|
10
|
+
<% filter_params = filter_params.to_h.with_indifferent_access if filter_params.respond_to?(:to_h) %>
|
|
11
|
+
<% field_name = filter_params[:n] || filter_params[:name] %>
|
|
12
|
+
<% field = fields.find { |af| af.name == field_name } %>
|
|
13
|
+
<% next unless field %>
|
|
14
|
+
<div class="typed-eav-filter" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: end;">
|
|
15
|
+
<div>
|
|
16
|
+
<label>Field</label>
|
|
17
|
+
<select name="f[][n]">
|
|
18
|
+
<option value="">--</option>
|
|
19
|
+
<% fields.each do |af| %>
|
|
20
|
+
<option value="<%= af.name %>" <%= "selected" if af.name == field_name %>>
|
|
21
|
+
<%= af.name.humanize %>
|
|
22
|
+
</option>
|
|
23
|
+
<% end %>
|
|
24
|
+
</select>
|
|
25
|
+
</div>
|
|
26
|
+
<div>
|
|
27
|
+
<label>Operator</label>
|
|
28
|
+
<select name="f[][op]">
|
|
29
|
+
<% field.class.supported_operators.each do |op| %>
|
|
30
|
+
<option value="<%= op %>"
|
|
31
|
+
<%= "selected" if op.to_s == (filter_params[:op] || filter_params[:operator]).to_s %>>
|
|
32
|
+
<%= typed_eav_operator_label(op) %>
|
|
33
|
+
</option>
|
|
34
|
+
<% end %>
|
|
35
|
+
</select>
|
|
36
|
+
</div>
|
|
37
|
+
<div>
|
|
38
|
+
<label>Value</label>
|
|
39
|
+
<input type="text" name="f[][v]"
|
|
40
|
+
value="<%= filter_params[:v] || filter_params[:value] %>" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<%# Empty filter row for adding new filters %>
|
|
46
|
+
<div class="typed-eav-filter" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: end;">
|
|
47
|
+
<div>
|
|
48
|
+
<label>Field</label>
|
|
49
|
+
<select name="f[][n]">
|
|
50
|
+
<option value="">--</option>
|
|
51
|
+
<% fields.each do |af| %>
|
|
52
|
+
<option value="<%= af.name %>"><%= af.name.humanize %></option>
|
|
53
|
+
<% end %>
|
|
54
|
+
</select>
|
|
55
|
+
</div>
|
|
56
|
+
<div>
|
|
57
|
+
<label>Operator</label>
|
|
58
|
+
<select name="f[][op]">
|
|
59
|
+
<option value="eq">equals</option>
|
|
60
|
+
<option value="not_eq">does not equal</option>
|
|
61
|
+
<option value="gt">greater than</option>
|
|
62
|
+
<option value="lt">less than</option>
|
|
63
|
+
<option value="contains">contains</option>
|
|
64
|
+
<option value="is_null">is empty</option>
|
|
65
|
+
<option value="is_not_null">is not empty</option>
|
|
66
|
+
</select>
|
|
67
|
+
</div>
|
|
68
|
+
<div>
|
|
69
|
+
<label>Value</label>
|
|
70
|
+
<input type="text" name="f[][v]" />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div style="margin-top: 0.5rem;">
|
|
76
|
+
<%= f.submit "Search" %>
|
|
77
|
+
<%= link_to "Clear", url %>
|
|
78
|
+
</div>
|
|
79
|
+
</fieldset>
|
|
80
|
+
<% end %>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%= form_with(model: field, scope: :typed_eav_field,
|
|
2
|
+
url: field.persisted? ? typed_eav_field_path(field) : typed_eav_fields_path) do |f| %>
|
|
3
|
+
<%= render "typed_eav/forms/common_fields", f: f, field: field %>
|
|
4
|
+
|
|
5
|
+
<div>
|
|
6
|
+
<%= f.label :default_value %>
|
|
7
|
+
<%= f.select :default_value, [["None", ""], ["True", "true"], ["False", "false"]],
|
|
8
|
+
{ selected: field.default_value.to_s } %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div><%= f.submit field.persisted? ? "Update" : "Create" %></div>
|
|
12
|
+
<% end %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%= form_with(model: field, scope: :typed_eav_field,
|
|
2
|
+
url: field.persisted? ? typed_eav_field_path(field) : typed_eav_fields_path) do |f| %>
|
|
3
|
+
<%= render "typed_eav/forms/common_fields", f: f, field: field %>
|
|
4
|
+
|
|
5
|
+
<div>
|
|
6
|
+
<%= f.label :default_value %>
|
|
7
|
+
<%= f.text_field :default_value, value: field.default_value %>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div><%= f.submit field.persisted? ? "Update" : "Create" %></div>
|
|
11
|
+
<% end %>
|