pxs-forms 0.0.15 → 0.0.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14b39bac9452c6f1c71de938ea6ef066dba69a0d861048f7f8c691af49b51ee6
4
- data.tar.gz: 1a96903d410a21e147dc3fcdfa00752178c7e45ae6dd2f3b3c3215a60069f18d
3
+ metadata.gz: c840e8cf5cad383cbd45ad80f54f696798d55eda8d6ffa805f30d9fe7ad08763
4
+ data.tar.gz: 94155c2dbfc0598169bde41a3d3af2cf0360fb3b3370a0cd981224b3fb05f903
5
5
  SHA512:
6
- metadata.gz: 638a4ed2810a3e316043aad98a4720cde8317645171694ef026081e0fabe93588541236a35dd3f6854013bb79a9a45df29f060ed907ee821c9629c0906253553
7
- data.tar.gz: ba16db0647f94210668ccf504bfa0c763ff62225be35cf431710f6466629ecd89d16fee5fe99387a8bacf188fa373c01fd88fe6792d5cdecd8a1dc0992f07eaf
6
+ metadata.gz: 6d7c6d96598f06fab708c128932378bec6930d6b28e9acd4e04b7c12410a0177610511389a41e7e0025dbecd2c6a1c7286bef6ca061c549cd9037c371d21aa40
7
+ data.tar.gz: 04301a70b9b5d953998592eb281d06442280f28c5a675df9887caeed51c40852a251a60d136805b74bac49cfad20891bce061955202e1585b930ef79a0044e77
data/CHANGELOG.md CHANGED
@@ -6,4 +6,14 @@
6
6
 
7
7
  ## [0.0.11] - 2024-04-19
8
8
 
9
- - Added currency field
9
+ - Added currency field
10
+
11
+ ## [0.0.16]
12
+
13
+ - Replace the method parameter by an options parameter to allow for modification of specific HTML elements on links
14
+ - Confirm options on links
15
+ - Smarter merging process on HTML attributes when using link helpers
16
+ - Use base input names and IDs in model form builder
17
+ - Add select or create subform as a model form builder method
18
+ - Add nested association form and related methods to models helper
19
+ - Add shorthand methods: model_form_for, model_form_with
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Pxs::Forms
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/pxs/forms`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Quick and dirty form helpers. Use this at your own peril until v0.1 because I'm still making breaking changes every now and then.
6
4
 
7
5
  ## Installation
8
6
 
@@ -18,7 +16,7 @@ If bundler is not being used to manage dependencies, install the gem by executin
18
16
 
19
17
  ## Usage
20
18
 
21
- TODO: Write usage instructions here
19
+ Needs a few keywords defined in i18n: t('actions.edit'), t('actions.delete'), t('actions.cancel'), t('actions.create'), t('actions.update')
22
20
 
23
21
  ## Development
24
22
 
@@ -1,12 +1,75 @@
1
1
  module Pxs
2
2
  module Forms
3
3
  module LinksHelper
4
- def turbo_link_button(text, target, method = :get)
5
- link_to text, target, class: "button", data: { turbo_stream: true, turbo_method: method }
4
+ include ActionView::Helpers
5
+
6
+ # a link that triggers a turbo request
7
+ def turbo_link_to(text, target, options = {})
8
+ link_to text, target, class: options[:class] || nil, data: { turbo_stream: true, turbo_method: options[:method] || :get, turbo_confirm: options[:confirm] }, id: options[:id], title: options[:title]
9
+ end
10
+
11
+ # a button that triggers a turbo request
12
+ def turbo_link_button(text, target, options = {})
13
+ # inject the button class to a turbo link
14
+ turbo_link_to text, target, merge_option(:class, "button", options)
15
+ end
16
+
17
+ # a button that makes an HTML request
18
+ def link_button_to(text, target, options = {})
19
+ link_to text, target, class: merge_option(:class, "button", options)[:class], method: options[:method] || :get, id: options[:id], title: options[:title]
20
+ end
21
+
22
+ private
23
+ ## merges a @value onto @options[@key], given a set of @options
24
+ # set @decompose to false if the value shouldn't be split into an array if its a string, as is the case for say an HTML class that could be class="button red"
25
+ def merge_option(key, merged_value, options = {}, decompose = true)
26
+ if options.has_key? key
27
+
28
+ # stuff both values into arrays, split if decomposing
29
+ merged_values = if decompose then merged_value.split else [merged_value] end
30
+
31
+ option_values = options[key]
32
+
33
+ # if decomposing, split the string into an array containing each word
34
+ if decompose && option_values.is_a?(String)
35
+ option_values = option_values.split
36
+ # for consistency, do the same with symbols as they will be processed like strings but cannot contain more than one element
37
+ elsif decompose && option_values.is_a?(Symbol)
38
+ option_values = [option_values.to_s]
39
+ # if value is a string and decompose is set to false or if it's anything else than an array
40
+ elsif (!decompose && option_values.is_a?(String)) ||!option_values.is_a?(Array)
41
+ # put the value as is into an array
42
+ option_values = [option_values]
43
+ end
44
+
45
+ # merged_values must always be an array here
46
+ merged_values.each do |merged_value|
47
+ # check for reasons not to merge the values, namely: it's already in @value
48
+ unless attr_array_contains(option_values, merged_value)
49
+ option_values << merged_value
50
+ end
51
+ end
52
+
53
+ options[key] = option_values.join(" ")
54
+ # otherwise, options does not have a [key] entry
55
+ else
56
+ # set the option as the merged_value
57
+ options[key] = merged_value
58
+ end
59
+
60
+ options
6
61
  end
7
62
 
8
- def link_button_to(text, target, method = :get)
9
- link_to text, target, class: "button", method: method
63
+ def attr_array_contains(array = [], value = nil)
64
+ if value
65
+ array.each do |array_value|
66
+ if array_value == value || array_value =~ /^#{value} / || array_value =~ / #{value}$/ || array_value.include?(" #{value} ")
67
+ return true
68
+ end
69
+ end
70
+ end
71
+
72
+ false
10
73
  end
11
74
  end
12
75
  end
@@ -2,6 +2,21 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
2
2
 
3
3
  delegate :tag, :safe_join, to: :@template
4
4
 
5
+ attr_reader :input_name_prefix, :input_id_prefix
6
+
7
+ def initialize(object_name, object, template, options, input_name_prefix = nil, input_id_prefix = nil)
8
+ # if input name isn't specified (which by default it isn't in the Rails vanilla form_with/for), set it from the object name
9
+ @input_name_prefix = input_name_prefix || object_name.to_s.underscore
10
+ @input_id_prefix = input_id_prefix || object_name.to_s.underscore.kebabcase
11
+
12
+ super(object_name, object, template, options)
13
+ end
14
+
15
+ def set_association_prefixes(name, id = "")
16
+ @input_name_prefix = name
17
+ @input_id_prefix = id
18
+ end
19
+
5
20
  def field(attribute, options = {})
6
21
  @form_options = options
7
22
  object_type = object_type_for_attribute(attribute)
@@ -9,6 +24,7 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
9
24
  input_type = case object_type
10
25
  when :date then :string
11
26
  when :integer then :string
27
+ when :decimal then :string
12
28
  else object_type
13
29
  end
14
30
 
@@ -21,15 +37,134 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
21
37
  send("#{override_input_type || input_type}_input", attribute, options)
22
38
  end
23
39
 
24
- def radio_buttons_field(attribute, options = {})
40
+ def radio_buttons_input(attribute, options = {})
25
41
  collection_of(:radio_buttons, attribute, options)
26
42
  end
27
43
 
28
- def check_boxes_field(attribute, options = {})
44
+ def check_boxes_input(attribute, options = {})
29
45
  collection_of(:check_boxes, attribute, options)
30
46
  end
31
47
 
48
+ # if the object is associated to other models, they can have their own subsection of a form
49
+ # the subsection typically includes:
50
+ # * a selection input for the submodel:
51
+ # usually a Select or Radio Buttons for single associations, select or checkboxes or custom components for multiple
52
+ # for now this is kept outside of this form builder, could be added here
53
+ # * an "add [model]" button in case the user can add the given model and the intended model does not exist yet
54
+ # * a space for the submodel's form, which is displayed only as necessary
55
+ def association_select_or_create_subform(association, options = {})
56
+
57
+ # assert that the options hash passed contains only valid keys
58
+ options.assert_valid_keys(:add, :select, :collection)
59
+ # merge with the default options
60
+ options = association_select_or_create_subform_options(association).merge(options)
61
+
62
+ # use the provided collection, if any
63
+ collection = if options[:collection].present? then
64
+ options[:collection]
65
+ # otherwise use Association.for_select, if that method is provided
66
+ elsif options[:association_class].methods.include? :for_select
67
+ options[:association_class].for_select
68
+ # otherwise use Association.all
69
+ else
70
+ options[:association_class].all
71
+ end
72
+
73
+ # the container for the input and whatever subform might appear
74
+ @template.tag.div id: model_html_id(association), class: "form-section" do
75
+
76
+ safe_join [
77
+ # subform title, acting as the field label
78
+ @template.tag.h3(association.to_s.humanize, id: model_html_id(association, :title)),
79
+ # a select input, to choose an existing model
80
+ field("#{association.to_s}_id", collection: collection, text_method: options[:select][:text_method], value_method: options[:select][:value_method]),
81
+ # an empty "client-form" ID div, to be filled with the subform fields once expanded
82
+ @template.tag.div(id: model_html_id(association, :form)),
83
+ # an "Add Client" button, to expand the subform when clicked in order to allow the user to create a new submodel alongside the original model
84
+ @template.link_to(options[:add][:label], options[:add][:path], class: "button", id: model_button_id(association, :new), data: { turbo_stream: true }),
85
+ # A `cancel` button to close the subform, initially hidden
86
+ cancel_form_button(association)
87
+ ]
88
+ end
89
+ end
90
+
91
+ def associated_model_subform association
92
+ fields_for association do |association_form|
93
+ @template.render("#{association_class(association).model_name.collection}/fields", form: association_form)
94
+ end
95
+ end
96
+
97
+ # cancel subform button
98
+ def cancel_form_button association, label = nil
99
+ @template.link_to(label ? label.to_.humanize : "Use Existing", new_associated_model_path(association, name: @input_name_prefix, args: {cancel: :true}), class: "button hidden", id: "cancel-#{association_class(association).model_name.singular.kebabcase}-button", data: {turbo_stream: true})
100
+ end
101
+
102
+ def slider_input(attribute, options = {})
103
+ field_block(attribute, options) do
104
+ safe_join [
105
+ (field_label(attribute, options[:label]) unless options[:label] == false),
106
+ tag.input(type: :range, id: "#{base_input_name}-#{attribute.to_s.kebabcase}", name: attribute.to_s.kebabcase, min: options[:min] || 0, max: options[:max] || 10, step: options[:step] || 1, value: options[:value], list: options[:datalist_id])
107
+ ]
108
+ end
109
+ end
110
+
111
+ # range inputs, by definition, accept two attributes, both assumed to be numerical
112
+ def range_input(min_attribute, max_attribute, options = {})
113
+ # default to lower boundary
114
+ #? worth forcing an error by requiring this parameter, instead of stashing in options; same for the min/max options, which are hard to give default values to
115
+ attribute_name = (options[:attribute_name] || min_attribute).to_s
116
+ field_block(attribute_name, options) do
117
+ safe_join [
118
+ (field_label(attribute_name, options[:label]) unless options[:label] == false),
119
+ tag.input(type: :range, id: "#{base_input_name}-#{attribute_name.kebabcase}", name: attribute_name.kebabcase, min: options[:min] || 0, max: options[:max] || 10, step: options[:step] || 1, value: options[:value], list: options[:datalist_id])
120
+ ]
121
+ end
122
+ end
123
+
124
+
32
125
  private
126
+ def cached_helpers
127
+ Class.new do
128
+ include Rails.application.routes.url_helpers
129
+ include Rails.application.routes.mounted_helpers
130
+ end.new
131
+ end
132
+
133
+ # if a collection is given as a hash (as it typically is for enums, for instance), it should be converted into an array in order to be fed to collection inputs
134
+ def collection_from_hash(collection, invert_keys_and_values: false, keys_as_label_and_text: false)
135
+ return collection.map {|k,v| if invert_keys_and_values then [k,v] elsif keys_as_label_and_text then [k,k] else [v,k] end}, :first, :last
136
+ end
137
+
138
+ def association_class(association)
139
+ object.class.reflect_on_association(association).klass
140
+ end
141
+
142
+ # calls the new_[model]_path helper function with the model contained in @association, and any params given
143
+ def new_associated_model_path(association, name: nil, args: {})
144
+ # name param is the form's path
145
+ cached_helpers.send("new_#{association_class(association).model_name.singular}_path", {name: name || field_name(object.class.model_name.singular, "#{association.to_s}_attributes")}.merge(args) )
146
+ end
147
+
148
+ # default options for an association select-or-create-with-subform field
149
+ def association_select_or_create_subform_options(association, name = nil)
150
+ # default options for the `Add` button
151
+ add = {
152
+ # add button default label
153
+ label: t("actions.new", thing: "#{association_class(association).model_name.human}"),
154
+ # add button default path
155
+ path: new_associated_model_path(association, name: name)
156
+ }
157
+
158
+ # default options for select inputs
159
+ select = {
160
+ text_method: :name,
161
+ value_method: :id
162
+ }
163
+
164
+ # save the association class as an option so it doesn't need to be called again if needed later
165
+ return { association_class: association_class(association), collection: [], add:, select: }
166
+ end
167
+
33
168
  def object_type_for_attribute(attribute)
34
169
  # if @object defines an attribute
35
170
  result = if @object.respond_to?(:type_for_attribute) && @object.has_attribute?(attribute)
@@ -47,6 +182,7 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
47
182
 
48
183
  def string_input(attribute, options = {})
49
184
  field_block(attribute, options) do
185
+
50
186
  safe_join [
51
187
  (field_label(attribute, options[:label]) unless options[:label] == false),
52
188
  string_field(attribute, merge_input_options({class: "#{"is-invalid" if has_error?(attribute)}"}, options[:input_html])),
@@ -66,7 +202,7 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
66
202
  field_block(attribute, options) do
67
203
  safe_join [
68
204
  (field_label(attribute, options[:label]) unless options[:label] == false),
69
- text_area(attribute, merge_input_options({class: "#{"is-invalid" if has_error?(attribute)}"}, options[:input_html])),
205
+ text_area(base_input_name, attribute, merge_input_options({class: "#{"is-invalid" if has_error?(attribute)}", value: object[attribute]}, options[:input_html])),
70
206
  ]
71
207
  end
72
208
  end
@@ -75,8 +211,8 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
75
211
  field_block(attribute, options) do
76
212
  tag.div(class: "checkbox-field") do
77
213
  safe_join [
78
- check_box(attribute, merge_input_options({class: "checkbox-input"}, options[:input_html])),
79
- label(attribute, options[:label], class: "checkbox-label"),
214
+ check_box(base_input_name, attribute, merge_input_options({class: "checkbox-input"}, options[:input_html])),
215
+ label(base_input_name, attribute, options[:label], class: "checkbox-label"),
80
216
  ]
81
217
  end
82
218
  end
@@ -85,23 +221,29 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
85
221
  def collection_input(attribute, options, &block)
86
222
  field_block(attribute, options) do
87
223
  safe_join [
88
- label(attribute, options[:label]),
224
+ options[:label] ? nil : label(object.class.to_s.underscore.downcase.to_sym, attribute, options[:label] || object.class.human_attribute_name(attribute)),
89
225
  block.call,
90
226
  ]
91
227
  end
92
228
  end
93
229
 
94
230
  def select_input(attribute, options = {})
95
-
96
- value_method = options[:value_method] || :id
97
- text_method = options[:text_method] || :name
231
+ # hash collections are a special case
232
+ if options[:collection].is_a? Hash
233
+ options[:collection], value_method, text_method = collection_from_hash(options[:collection], invert_keys_and_values: options[:invert_keys_and_values], keys_as_label_and_text: options[:keys_as_label_and_text])
234
+ else
235
+ value_method = options[:value_method] || :id
236
+ text_method = options[:text_method] || :name
237
+ end
98
238
  input_options = options || {}
99
239
 
100
240
  multiple = input_options[:multiple]
101
241
 
102
- collection_input(attribute, options) do
103
- collection_select(attribute, options[:collection], value_method, text_method, options[:select_options] || {}, merge_input_options({class: "#{"custom-select" unless multiple} #{"is-invalid" if has_error?(attribute)}"}, options[:input_html]))
104
- end
242
+ safe_join [
243
+ collection_input(attribute, options) do
244
+ collection_select(object.class.model_name.singular.to_sym, attribute, options[:collection], value_method, text_method, {selected: object[attribute]}.merge(options[:select] || {}), merge_input_options({class: "#{"custom-select" unless multiple} #{"is-invalid" if has_error?(attribute)}"}, options[:input_html]))
245
+ end
246
+ ]
105
247
  end
106
248
 
107
249
  def grouped_select_input(attribute, options = {})
@@ -116,7 +258,7 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
116
258
  field_block(attribute, options) do
117
259
  safe_join [
118
260
  (field_label(attribute, options[:label]) unless options[:label] == false),
119
- custom_file_field(attribute, options),
261
+ custom_file_field(base_input_name, attribute, options),
120
262
  ]
121
263
  end
122
264
  end
@@ -132,10 +274,10 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
132
274
  safe_join [
133
275
  field_label(attribute, options[:label]),
134
276
  tag.div(class: "#{custom_class}-list") {
135
- (send(form_builder_method, attribute, options[:collection], options[:value_method], options[:text_method]) do |b|
277
+ (send(form_builder_method, object.model_name.singular.to_sym, attribute, options[:collection], options[:value_method], options[:text_method]) do |b|
136
278
  tag.div(class: "#{custom_class}") {
137
279
  safe_join [
138
- b.send(input_builder_method, class: "#{custom_class}-input"),
280
+ b.send(input_builder_method, {class: "#{custom_class}-input"}.merge(options[:item_html] || {}) {|k, v1, v2| [v1, v2].join(" ")}),
139
281
  b.label(class: "#{custom_class}-label"),
140
282
  ]
141
283
  }
@@ -146,10 +288,12 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
146
288
  end
147
289
 
148
290
  def string_field(attribute, options = {})
291
+ # force the default value against the object's attribute, because using :object in lieu of the object name will yield HTML elements with name="object[some_attribute]" instead of the proper name
292
+ options = { value: object[attribute] }.merge(options)
149
293
  case object_type_for_attribute(attribute)
150
294
  when :date then
151
295
  safe_join [
152
- date_field(attribute, merge_input_options(options, {data: {datepicker: true}})),
296
+ date_field(base_input_name, attribute, merge_input_options(options, {data: {datepicker: true}})),
153
297
  tag.div {
154
298
  date_select(attribute, {
155
299
  order: [:year, :month, :day],
@@ -158,19 +302,18 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
158
302
  }, {data: {date_select: true}})
159
303
  },
160
304
  ]
161
- when :datetime then
162
- datetime_field(attribute, options)
163
- when :integer then number_field(attribute, options)
305
+ when :integer then number_field(base_input_name, attribute, options)
306
+ when :decimal then number_field(base_input_name, attribute, {step: 0.1}.merge(options))
164
307
  when :string
165
308
  case attribute.to_s
166
- when /password/ then password_field(attribute, options)
309
+ when /password/ then password_field(base_input_name, attribute, options)
167
310
  # when /time_zone/ then :time_zone
168
311
  # when /country/ then :country
169
- when /email/ then email_field(attribute, options)
170
- when /phone/ then telephone_field(attribute, options)
171
- when /url/ then url_field(attribute, options)
312
+ when /email/ then email_field(base_input_name, attribute, options)
313
+ when /phone/ then telephone_field(base_input_name, attribute, options)
314
+ when /url/ then url_field(base_input_name, attribute, options)
172
315
  else
173
- text_field(attribute, options)
316
+ text_field(base_input_name, attribute, options)
174
317
  end
175
318
  end
176
319
  end
@@ -186,7 +329,7 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
186
329
  end
187
330
 
188
331
  def field_label(attribute, options = {})
189
- label(attribute, options)
332
+ label(object.class.model_name.singular_route_key.kebabcase.to_sym, I18n.t("activerecord.attributes.#{object.class.model_name.singular}.#{attribute}", default: attribute), options)
190
333
  end
191
334
 
192
335
  def hint_text(text)
@@ -197,7 +340,7 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
197
340
 
198
341
  def error_text(attribute)
199
342
  if has_error? attribute
200
- tag.div @object.errors[attribute].join("<br />").html_safe, class: "form-errors"
343
+ tag.div @object.errors[method].join("<br />").html_safe, class: "form-errors"
201
344
  end
202
345
  end
203
346
 
@@ -206,10 +349,18 @@ class ModelFormBuilder < ActionView::Helpers::FormBuilder
206
349
  @object.errors.key?(attribute)
207
350
  end
208
351
 
209
- def merge_input_options(options, user_options)
210
- return options if user_options.nil?
352
+ def merge_input_options(options, user_options = nil)
353
+ options = if user_options.nil?
354
+ options
355
+ else
356
+ options.merge(user_options)
357
+ end
358
+
359
+ # filter out hash keys with an empty/nil value
360
+ options.filter do |key,value| value.present? end
361
+ end
211
362
 
212
- # TODO handle class merging here
213
- options.merge(user_options)
363
+ def base_input_name
364
+ @input_name_prefix || @object.class.model_name.singular.to_sym
214
365
  end
215
366
  end
@@ -1,43 +1,91 @@
1
1
  module Pxs
2
2
  module Forms
3
3
  module ModelsHelper
4
+ include LinksHelper
5
+ include ActionView::Context
6
+
4
7
  # base_path: the model's CRUD base path as an URL string, like the result of article_path(:id)
5
- def model_edit_delete_actions(base_path)
8
+ def model_edit_delete_actions(base_path, options = {})
6
9
  [
7
10
  # edit model button
8
- model_edit_action(base_path),
11
+ model_edit_action(base_path, options[:edit_label]),
9
12
  # delete model button
10
- model_delete_action(base_path)
13
+ model_delete_action(base_path, options[:delete_label])
11
14
  ].join('').html_safe
12
15
  end
13
16
 
14
- def model_edit_action(base_path)
17
+ # "Edit" model button
18
+ def model_edit_action(base_path, label = nil)
15
19
  #! maybe there's a safer way to do the route but this works as long as rails routing naming conventions hold
16
- turbo_link_button(t('keywords.edit'), "#{base_path}/edit")
20
+ turbo_link_button(label || t('keywords.edit'), "#{base_path}/edit")
21
+ end
22
+
23
+ # "Delete" model button
24
+ def model_delete_action(base_path, label = nil)
25
+ turbo_link_button(label || t('keywords.delete'), base_path, method: :delete )
26
+ end
27
+
28
+ # "New" model button
29
+ def model_new_action(base_path, label = nil)
30
+ turbo_link_button(label || t('keywords.new'), "#{base_path}/new")
31
+ end
32
+
33
+ # "Cancel" new model button
34
+ def model_cancel_form_button(base_path, label = nil)
35
+ turbo_link_button(label || t('keywords.cancel'), "#{base_path}?cancel=true")
17
36
  end
18
37
 
19
- def model_delete_action(base_path)
20
- turbo_link_button(t('keywords.delete'), base_path, :delete )
38
+ # "Create"/"Update" model model
39
+ def model_submit_button(form, label = nil)
40
+ # adjust the label depending on whether the form's object has already been created or not
41
+ label = label || "#{(form.object.new_record? ? t("keywords.create") : t("keywords.update"))} #{form.object.class.to_s}"
42
+ form.submit label
21
43
  end
22
44
 
23
- def model_cancel_form_button(base_path)
24
- turbo_link_button(t('keywords.cancel'), "#{base_path}/0")
45
+ # Model element HTML tag ID
46
+ def model_html_id(model, suffix = "")
47
+ # model-suffix, or only model if no suffix is provided
48
+ "#{model_string(model).kebabcase}#{suffix.present? ? "-#{suffix.to_s.kebabcase}" : ""}"
49
+ end
50
+
51
+ # Model subform title HTML ID
52
+ def model_title_id(model)
53
+ # some-model-title
54
+ model_html_id model.class.model_name.singular.kebabcase, :title
55
+ end
56
+
57
+ # Model subform <form> HTML ID
58
+ def model_form_id(model)
59
+ # use the model class, not the model itself
60
+ if model.is_a? ActiveRecord::Base
61
+ model_class = model
62
+ else
63
+ model_class = model.class
64
+ end
65
+
66
+ model_html_id model_class.model_name.singular.kebabcase, :form
25
67
  end
26
68
 
27
- def model_submit_button(form)
28
- form.submit "#{form.object.new_record? ? "Create" : "Update"} #{form.object.class.to_s}"
69
+ # shorthand to set model form button IDs
70
+ # for instance, model_button_id(client, :new) == 'new-client-button'
71
+ def model_button_id(model, button_action = nil)
72
+ model_html_id "#{button_action.present? ? "#{button_action.to_s.kebabcase}-" : ""}#{model_string(model).kebabcase}", :button
29
73
  end
30
74
 
75
+ # standardized form call for models (using form_for)
31
76
  def model_form_for(name, *args, &block)
32
77
  # set ModelFormBuilder as the form builder
78
+ #! can be avoided by setting ModelFormBuilder as default in config
33
79
  options = args.extract_options!.merge(builder: ModelFormBuilder)
34
80
 
81
+ #! use merge_option
35
82
  if options.has_key? :class
36
83
  options[:class] << " form"
37
84
  else
38
85
  options[:class] = "form"
39
86
  end
40
87
 
88
+ # separate HTML options due to form_for parameter strucutre
41
89
  options[:html] = { class: options[:class] }
42
90
 
43
91
  args << options
@@ -45,10 +93,121 @@ module Pxs
45
93
  form_for(name, *args, &block)
46
94
  end
47
95
 
48
- def model_form_with(model: nil, scope: nil, url: nil, format: nil, **options, &block)
96
+ # standardized form call for models (using form_with)
97
+ def model_form_with(model, scope: nil, url: nil, format: nil, **options, &block)
98
+ # set custom form builder (!should be done in config)
49
99
  options = options.reverse_merge(builder: ModelFormBuilder)
100
+
101
+ # make the call
50
102
  form_with(model: model, scope: scope, url: url, format: format, class: "form#{(options.has_key? :class) ? " #{options[:class]}" : ""}", **options, &block)
51
103
  end
104
+
105
+ #!? what does this do again?
106
+ def model_string model
107
+ if model.is_a? String
108
+ model
109
+ elsif model.is_a? ActiveRecord::Base || model.respond_to?(model_name)
110
+ model.model_name.singular
111
+ else
112
+ model.to_s
113
+ end
114
+ end
115
+
116
+ # form_name => a string that matches the input elements HTML name attribute, like "project[client][contacts][0][coordinate]"
117
+ def model_association_form(form_name = "")
118
+
119
+ # isolate the original model class from the submodels
120
+ form_model, *nested_attributes = form_name.split(/\[|\]/).compact_blank
121
+
122
+ # simulate the top-level form using a new instance of form_model
123
+ fields form_model.classify.constantize.new do |form|
124
+ form.set_association_prefixes(form_name)
125
+
126
+ # recursively build the subforms, going as deep as needed to reach the association model form
127
+ nested_form_builder_for form, nested_attributes do |nested_form|
128
+
129
+ # run once for the last iteration (the association model)
130
+ return nested_form
131
+ end
132
+ end
133
+ end
134
+
135
+ # to convert something like "project[client_attributes]" to "project-client"
136
+ def input_name_to_id(name)
137
+ match = match_model_input_name name
138
+
139
+ # return nil on bad input
140
+ return nil unless match
141
+
142
+ # create a working copy because will be modified later
143
+ working_name = name
144
+
145
+ # first capture group is the starting point of the ID, to kebabcase
146
+ id = match[1].kebabcase
147
+
148
+ loop do
149
+ if match[2]
150
+
151
+ # only match with character strings, ignore indexes for now
152
+ #! might lead to some issues if ever there comes a point where this is used in one-to-many relationships with multiple models displayed on the same page
153
+ if association = match[2].match(/\[([A-Za-z_]+)\]/)
154
+ id << "-#{association[1].kebabcase}"
155
+ end
156
+ end
157
+
158
+ # break after the first check, so the first set of bracket is processed coming into the loop
159
+ break unless match[2]
160
+
161
+ # remove the first set of [] from the name string, intending to repeat the process over and over until each has been removed
162
+ working_name.gsub!(match[2], "")
163
+
164
+ # match again
165
+ match = match_model_input_name working_name
166
+ end
167
+
168
+ # id should contain the base model and associated models in a kebabcase string, like "project-client-contacts-first-name"
169
+ id
170
+ end
171
+
172
+ def input_name_to_select_id(name)
173
+ match = match_model_input_name name
174
+
175
+ return nil unless match[3]
176
+
177
+ object_name = match[1]
178
+ association_name = match[3].chomp("_attributes")
179
+
180
+ "#{object_name}_#{association_name}"
181
+ end
182
+
183
+ private
184
+ def match_model_input_name(name)
185
+ name.match(/([A-Za-z]+)(\[([a-zA-Z_]+|[\d]+)\])?/)
186
+ end
187
+ # recursively build a nested form from a given form builder and nested_attributes, which more or less matches the name="" HTML attribute of the input fields that must ultimately be produced
188
+ def nested_form_builder_for form, *nested_attributes, &block
189
+
190
+ # fetch the attribute and potential index from the nested attributes
191
+ attribute, index = nested_attributes.flatten!.shift(2)
192
+
193
+ if attribute.blank?
194
+ # if running out of attributes, render the last form builder instance to generate the response
195
+ yield form
196
+ return
197
+ end
198
+
199
+ # get the assoication from the attribute name, if applicable
200
+ association = attribute.chomp("_attributes")
201
+
202
+ # set the index, generate a unique one if none was provided in the nested attributes
203
+ child_index = index || Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
204
+
205
+ return form.fields_for association, association.classify.constantize.new, child_index: do |association_form|
206
+
207
+ association_form.set_association_prefixes(form.input_name_prefix, form.input_id_prefix)
208
+ nested_form_builder_for association_form, nested_attributes, &block
209
+ end
210
+ end
52
211
  end
53
212
  end
54
213
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Pxs
4
4
  module Forms
5
- VERSION = "0.0.15"
5
+ VERSION = "0.0.17"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pxs-forms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.15
4
+ version: 0.0.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Poubelle
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-05-05 00:00:00.000000000 Z
11
+ date: 2025-03-01 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email: