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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +494 -0
  5. data/Rakefile +13 -0
  6. data/app/models/typed_eav/application_record.rb +7 -0
  7. data/app/models/typed_eav/field/base.rb +234 -0
  8. data/app/models/typed_eav/field/boolean.rb +22 -0
  9. data/app/models/typed_eav/field/color.rb +16 -0
  10. data/app/models/typed_eav/field/date.rb +24 -0
  11. data/app/models/typed_eav/field/date_array.rb +34 -0
  12. data/app/models/typed_eav/field/date_time.rb +29 -0
  13. data/app/models/typed_eav/field/decimal.rb +30 -0
  14. data/app/models/typed_eav/field/decimal_array.rb +31 -0
  15. data/app/models/typed_eav/field/email.rb +40 -0
  16. data/app/models/typed_eav/field/integer.rb +30 -0
  17. data/app/models/typed_eav/field/integer_array.rb +68 -0
  18. data/app/models/typed_eav/field/json.rb +26 -0
  19. data/app/models/typed_eav/field/long_text.rb +19 -0
  20. data/app/models/typed_eav/field/multi_select.rb +41 -0
  21. data/app/models/typed_eav/field/select.rb +28 -0
  22. data/app/models/typed_eav/field/text.rb +41 -0
  23. data/app/models/typed_eav/field/text_array.rb +36 -0
  24. data/app/models/typed_eav/field/url.rb +40 -0
  25. data/app/models/typed_eav/option.rb +24 -0
  26. data/app/models/typed_eav/section.rb +25 -0
  27. data/app/models/typed_eav/value.rb +149 -0
  28. data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
  29. data/lib/generators/typed_eav/install/install_generator.rb +28 -0
  30. data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
  31. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
  32. data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
  33. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
  34. data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
  35. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
  36. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
  37. data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
  38. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
  39. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
  40. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
  41. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
  42. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
  43. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
  44. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
  45. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
  46. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
  47. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
  48. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
  49. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
  50. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
  51. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
  52. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
  53. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
  54. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
  55. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
  56. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
  57. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
  58. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
  59. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
  60. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
  61. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
  62. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
  63. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
  64. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
  65. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
  66. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
  67. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
  68. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
  69. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
  70. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
  71. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
  72. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
  73. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
  74. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
  75. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
  76. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
  77. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
  78. data/lib/typed_eav/column_mapping.rb +64 -0
  79. data/lib/typed_eav/config.rb +91 -0
  80. data/lib/typed_eav/engine.rb +20 -0
  81. data/lib/typed_eav/has_typed_eav.rb +484 -0
  82. data/lib/typed_eav/query_builder.rb +133 -0
  83. data/lib/typed_eav/registry.rb +52 -0
  84. data/lib/typed_eav/version.rb +5 -0
  85. data/lib/typed_eav.rb +86 -0
  86. 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
@@ -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
+ }
@@ -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 %>