compony 0.5.9 → 0.6.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +21 -0
  5. data/VERSION +1 -1
  6. data/compony.gemspec +3 -3
  7. data/config/locales/de.yml +13 -0
  8. data/config/locales/en.yml +13 -0
  9. data/config/locales/fr.yml +14 -1
  10. data/doc/ComponentGenerator.html +16 -4
  11. data/doc/Components.html +3 -3
  12. data/doc/ComponentsGenerator.html +3 -3
  13. data/doc/Compony/Component.html +4 -4
  14. data/doc/Compony/ComponentMixins/Default/Labelling.html +3 -3
  15. data/doc/Compony/ComponentMixins/Default/Standalone/ResourcefulVerbDsl.html +3 -3
  16. data/doc/Compony/ComponentMixins/Default/Standalone/StandaloneDsl.html +3 -3
  17. data/doc/Compony/ComponentMixins/Default/Standalone/VerbDsl.html +3 -3
  18. data/doc/Compony/ComponentMixins/Default/Standalone.html +3 -3
  19. data/doc/Compony/ComponentMixins/Default.html +3 -3
  20. data/doc/Compony/ComponentMixins/Resourceful.html +4 -4
  21. data/doc/Compony/ComponentMixins.html +3 -3
  22. data/doc/Compony/Components/Button.html +3 -3
  23. data/doc/Compony/Components/Destroy.html +3 -3
  24. data/doc/Compony/Components/Edit.html +3 -3
  25. data/doc/Compony/Components/Form.html +3 -3
  26. data/doc/Compony/Components/Index.html +172 -0
  27. data/doc/Compony/Components/List.html +2731 -0
  28. data/doc/Compony/Components/New.html +3 -3
  29. data/doc/Compony/Components/Show.html +740 -0
  30. data/doc/Compony/Components/WithForm.html +3 -3
  31. data/doc/Compony/Components.html +5 -5
  32. data/doc/Compony/ControllerMixin.html +3 -3
  33. data/doc/Compony/Engine.html +3 -3
  34. data/doc/Compony/MethodAccessibleHash.html +3 -3
  35. data/doc/Compony/ModelFields/Anchormodel.html +136 -4
  36. data/doc/Compony/ModelFields/Association.html +4 -4
  37. data/doc/Compony/ModelFields/Attachment.html +4 -4
  38. data/doc/Compony/ModelFields/Base.html +143 -13
  39. data/doc/Compony/ModelFields/Boolean.html +136 -4
  40. data/doc/Compony/ModelFields/Color.html +4 -4
  41. data/doc/Compony/ModelFields/Currency.html +4 -4
  42. data/doc/Compony/ModelFields/Date.html +4 -4
  43. data/doc/Compony/ModelFields/Datetime.html +4 -4
  44. data/doc/Compony/ModelFields/Decimal.html +4 -4
  45. data/doc/Compony/ModelFields/Email.html +4 -4
  46. data/doc/Compony/ModelFields/Float.html +4 -4
  47. data/doc/Compony/ModelFields/Integer.html +83 -5
  48. data/doc/Compony/ModelFields/Percentage.html +4 -4
  49. data/doc/Compony/ModelFields/Phone.html +4 -4
  50. data/doc/Compony/ModelFields/RichText.html +4 -4
  51. data/doc/Compony/ModelFields/String.html +4 -4
  52. data/doc/Compony/ModelFields/Text.html +4 -4
  53. data/doc/Compony/ModelFields/Time.html +4 -4
  54. data/doc/Compony/ModelFields/Url.html +4 -4
  55. data/doc/Compony/ModelFields.html +3 -3
  56. data/doc/Compony/ModelMixin.html +26 -26
  57. data/doc/Compony/NaturalOrdering.html +3 -3
  58. data/doc/Compony/RequestContext.html +3 -3
  59. data/doc/Compony/Version.html +3 -3
  60. data/doc/Compony/ViewHelpers.html +3 -3
  61. data/doc/Compony.html +4 -4
  62. data/doc/ComponyController.html +3 -3
  63. data/doc/_index.html +25 -4
  64. data/doc/class_list.html +3 -6
  65. data/doc/css/full_list.css +3 -3
  66. data/doc/css/style.css +0 -6
  67. data/doc/file.README.html +37 -10
  68. data/doc/file_list.html +2 -5
  69. data/doc/frames.html +5 -10
  70. data/doc/index.html +37 -10
  71. data/doc/js/app.js +264 -294
  72. data/doc/js/full_list.js +4 -30
  73. data/doc/method_list.html +423 -114
  74. data/doc/top-level-namespace.html +3 -3
  75. data/lib/compony/components/index.rb +33 -0
  76. data/lib/compony/components/list.rb +418 -0
  77. data/lib/compony/components/show.rb +116 -0
  78. data/lib/compony/model_fields/anchormodel.rb +13 -0
  79. data/lib/compony/model_fields/base.rb +12 -0
  80. data/lib/compony/model_fields/boolean.rb +13 -0
  81. data/lib/compony/model_fields/integer.rb +3 -0
  82. data/lib/compony/model_mixin.rb +5 -0
  83. data/lib/compony.rb +3 -0
  84. data/lib/generators/component/component_generator.rb +6 -0
  85. data/lib/generators/component/templates/index.rb.erb +2 -0
  86. data/lib/generators/component/templates/list.rb.erb +7 -0
  87. data/lib/generators/component/templates/show.rb.erb +2 -0
  88. metadata +10 -1
@@ -6,7 +6,7 @@
6
6
  <title>
7
7
  Top Level Namespace
8
8
 
9
- &mdash; Documentation by YARD 0.9.37
9
+ &mdash; Documentation by YARD 0.9.34
10
10
 
11
11
  </title>
12
12
 
@@ -102,9 +102,9 @@
102
102
  </div>
103
103
 
104
104
  <div id="footer">
105
- Generated on Fri Aug 29 14:58:40 2025 by
105
+ Generated on Fri Sep 5 14:00:24 2025 by
106
106
  <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
107
- 0.9.37 (ruby-3.3.5).
107
+ 0.9.34 (ruby-3.3.5).
108
108
  </div>
109
109
 
110
110
  </div>
@@ -0,0 +1,33 @@
1
+ module Compony
2
+ module Components
3
+ # @api description
4
+ # This component is used for the Rails index paradigm. Nests the `::List` component of the same family.
5
+ class Index < Compony::Component
6
+ include Compony::ComponentMixins::Resourceful
7
+
8
+ setup do
9
+ standalone path: family_name do
10
+ verb :get do
11
+ authorize { can? :index, data_class }
12
+ end
13
+ end
14
+
15
+ label(:all) { data_class.model_name.human(count: 2) }
16
+
17
+ load_data do
18
+ @data = data_class.accessible_by(controller.current_ability)
19
+ end
20
+
21
+ action :new do
22
+ if Compony.comp_class_for(:new, data_class)
23
+ Compony.button(:new, data_class.model_name.plural)
24
+ end
25
+ end
26
+
27
+ content do
28
+ concat resourceful_sub_comp(component.class.module_parent.const_get(:List)).render(controller)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,418 @@
1
+ module Compony
2
+ module Components
3
+ # @api description
4
+ # This component is used for the Rails list paradigm. It is meant to be nested inside the same family's `::Index` or an owner's `::Show` component.
5
+ class List < Compony::Component
6
+ include Compony::ComponentMixins::Resourceful
7
+
8
+ # The following parameters are meant for the case where this component is nested and therefore instanciated by a parent comp.
9
+ # If the component is to configure itself, use the DSL calls below instead.
10
+ # @param skip_pagination [Boolean] Disables pagination (caution: all records will be loaded)
11
+ # @param results_per_page [Integer] In case pagination is active, defines the amount of records to display per page.
12
+ # @param skip_filtering [Boolean] Disables filtering entirely (sorting is independent of this setting).
13
+ # @param skip_sorting [Boolean] Disables sorting entirely (both links and sorting input in filter).
14
+ # @param skip_sorting_in_filter [Boolean] Disables sorting in filter.
15
+ # @param skip_sorting_links [Boolean] Disables sorting links.
16
+ # @param skip_columns [Array] Column names to be skipped.
17
+ # @param skip_row_actions [Array] Row action names to be skipped.
18
+ # @param skip_filters [Array] Filter names to be skipped.
19
+ def initialize(*,
20
+ skip_pagination: false,
21
+ results_per_page: 20,
22
+ skip_filtering: false,
23
+ skip_sorting: false,
24
+ skip_sorting_in_filter: false,
25
+ skip_sorting_links: false,
26
+ skip_columns: [],
27
+ skip_row_actions: [],
28
+ skip_filters: [],
29
+ **)
30
+ @pagination = !skip_pagination
31
+ @results_per_page = results_per_page
32
+ @filtering = !skip_filtering
33
+ @sorting_in_filter = !skip_sorting && !skip_sorting_in_filter
34
+ @sorting_links = !skip_sorting && !skip_sorting_links
35
+ @columns = Compony::NaturalOrdering.new
36
+ @row_actions = Compony::NaturalOrdering.new
37
+ @skipped_columns = skip_columns.map(&:to_sym)
38
+ @skipped_row_actions = skip_row_actions.map(&:to_sym)
39
+ @filters = Compony::NaturalOrdering.new
40
+ @sorts = Compony::NaturalOrdering.new
41
+ @skipped_filters = skip_filters.map(&:to_sym)
42
+ @filter_label_class = 'list-filter-label'
43
+ @filter_input_class = 'list-filter-input'
44
+ @filter_select_class = 'list-filter-select'
45
+ @filter_item_wrapper_class = nil
46
+ super(*, **)
47
+ end
48
+
49
+ # DSL method
50
+ # Disables pagination (caution: all records will be loaded).
51
+ def skip_pagination!
52
+ @pagination = false
53
+ end
54
+
55
+ # DSL method
56
+ # In case pagination is active, defines the amount of records to display per page.
57
+ def results_per_page(new_results_per_page)
58
+ @results_per_page = new_results_per_page
59
+ end
60
+
61
+ # DSL method
62
+ # Disables filtering entirely (sorting is independent of this setting).
63
+ def skip_filtering!
64
+ @filtering = false
65
+ end
66
+
67
+ # DSL method
68
+ # Disables sorting entirely (both links and sorting input in filter).
69
+ def skip_sorting!
70
+ @sorting_in_filter = false
71
+ @sorting_links = false
72
+ end
73
+
74
+ # DSL method
75
+ # Disables sorting in filter.
76
+ def skip_sorting_in_filter!
77
+ @sorting_in_filter = false
78
+ end
79
+
80
+ # DSL method
81
+ # Disables sorting links.
82
+ def skip_sorting_links!
83
+ @sorting_links = false
84
+ end
85
+
86
+ # DSL method
87
+ # Sets the CSS class attribute for form label elements in filters.
88
+ # @param class_str [String] Space-separated list of CSS classes
89
+ def filter_label_class(class_str)
90
+ @filter_label_class = class_str
91
+ end
92
+
93
+ # DSL method
94
+ # Sets the CSS class attribute for string form inputs in filters.
95
+ # @param class_str [String] Space-separated list of CSS classes
96
+ def filter_input_class(class_str)
97
+ @filter_input_class = class_str
98
+ end
99
+
100
+ # DSL method
101
+ # Sets the CSS class attribute for form select inputs in filters.
102
+ # @param class_str [String] Space-separated list of CSS classes
103
+ def filter_select_class(class_str)
104
+ @filter_select_class = class_str
105
+ end
106
+
107
+ # DSL method
108
+ # Sets the CSS class attribute for the div that wraps input-related elements in filters (inputs, selects, labels).
109
+ # @param class_str [String] Space-separated list of CSS classes
110
+ def filter_item_wrapper_class(class_str)
111
+ @filter_item_wrapper_class = class_str
112
+ end
113
+
114
+ # DSL method
115
+ # Adds a new column to the list. If `name` corresponds to that of a field, everything is auto-inferred.
116
+ # Custom columns can be added by providing at least `label` and a block that will be given a record and instance-execed for every row.
117
+ # Please note that the column is only shown if the current user has permission to index the attribute.
118
+ # @param name [String] Name of the field that is supposed to be displayed. If custom name, make sure the user has the permission to index the attribute.
119
+ # @param label [String] Title of the column to be displayed in the table header.
120
+ # @param class [String] Space-separated list of CSS classes for each cell
121
+ # @param link_opts [Hash] Only used in the case of a model field, this is used to pass options to the field's `value_for`.
122
+ # @param block [Block] Custom block, given the record and instance-execed in the context of the cell for every row.
123
+ def column(name, label: nil, class: nil, link_opts: {}, **, &block)
124
+ name = name.to_sym
125
+ unless block_given?
126
+ # Assume field column
127
+ field = data_class.fields[name] || fail("Field #{name.inspect} was not found for data class #{data_class}")
128
+ block = proc do |record|
129
+ if controller.current_ability.permitted_attributes(:index, record).include?(field.name.to_sym)
130
+ next field.value_for(record, link_to_component: :show, controller:, link_opts:).to_s
131
+ end
132
+ end
133
+ end
134
+ @columns.natural_push(name, block, label: label || field.label, class:, **)
135
+ end
136
+
137
+ # DSL method
138
+ # Adds multiple columns at once, sharing the same kwargs.
139
+ def columns(*col_names, **)
140
+ col_names.each { |col_name| column(col_name, **) }
141
+ end
142
+
143
+ # DSL method
144
+ # Marks a single column as skipped. It will not be displayed, even if it is defined.
145
+ # @param name [Symbol,String] Name of the column to be skipped.
146
+ def skip_column(name)
147
+ @skipped_columns << name.to_sym
148
+ end
149
+
150
+ # DSL method
151
+ # Adds a row action. The very last col provides actions such as :show, :edit or :destroy. Use this method to add your own.
152
+ # In case the action exists as a component in the family of `data_class`, it is enough to pass the action's name, and the rest is auto-generated.
153
+ # In order to create a custom row action, pass a block that will be given the current record and instance-execed once per row, for every record.
154
+ # @param name [Symbol, String] The name of the action (e.g. :edit).
155
+ # @param button_opts [Hash] Only relevant in case of an auto-generated row action, this allows to configure the generated button.
156
+ # @param block [Block] To create a custom row action; block will be given the current record and instance-execed once per row, for every record.
157
+ def row_action(name, button_opts: {}, **, &block)
158
+ name = name.to_sym
159
+ unless block_given?
160
+ block = proc do |record|
161
+ next if Compony.comp_class_for(name, record).nil?
162
+ compony_button(name, record, **button_opts)
163
+ end
164
+ end
165
+ @row_actions.natural_push(name, block, **)
166
+ end
167
+
168
+ # DSL method
169
+ # Marks a single row action as skipped. It will not be displayed, even if it is defined.
170
+ # @param name [Symbol,String] Name of the row action to be skipped.
171
+ def skip_row_action(name)
172
+ @skipped_row_actions << name.to_sym
173
+ end
174
+
175
+ # DSL method
176
+ # Adds a ransack filter. If `name` is the name of an existing model field, the filter is auto-generated.
177
+ # If `name` is a valid Ransack search string (e.g. `id_eq`), all you need to pass is `name` and `label`.
178
+ # To create a fully custom filter, pass `name` and `block`. The block will be given the Ransack search form and should return HTML.
179
+ # @param name [String] The name of the filter. Can either be the name of a field, a ransack search string or a custom name (see above).
180
+ # @param label [String] The text to use in the input's label.
181
+ # @param block [Block] Custom block that will be given the Ransack search form and should produce a label and a search input.
182
+ def filter(name, label: nil, **, &block)
183
+ name = name.to_sym
184
+ unless block_given?
185
+ field = data_class.fields[name]
186
+ block ||= proc do |f|
187
+ label ||= field.label if field
188
+ fail("You must provide a label to filter #{name.inspect}.") unless label
189
+
190
+ if field
191
+ filter_name = field.ransack_filter_name
192
+ filter_input_html = capture { field.ransack_filter_input(f, filter_input_class: @filter_input_class, filter_select_class: @filter_select_class) }
193
+ else
194
+ filter_name = name
195
+ filter_input_html = capture { f.search_field(filter_name, class: @filter_input_class) }
196
+ end
197
+ div tag.label(label, for: filter_name, class: @filter_label_class), class: @filter_item_wrapper_class
198
+ div filter_input_html, class: @filter_item_wrapper_class
199
+ end
200
+ end
201
+
202
+ @filters.natural_push(name, block, **)
203
+ end
204
+
205
+ # DSL method
206
+ # Adds multiple filters at once, sharing the same kwargs.
207
+ def filters(*filter_names, **)
208
+ filter_names.each { |filter_name| filter(filter_name, **) }
209
+ end
210
+
211
+ # DSL method
212
+ # Adds a sorting criterion that will be processed by ransack. `data_class` must be sortable by this criterion. See Ransack's sorting for constraints.
213
+ # For every call of this method, one sorting link and two entries (asc, desc) in the sorting-in-filter feature will be generated, if enabled.
214
+ # @param name [Symbol, String] Sorting criteria, e.g. `:id` or `:label`.
215
+ # @param label [String] Label of the sorting link / entries.
216
+ def sort(name, label: nil)
217
+ label ||= data_class.fields[name].label
218
+ @sorts.natural_push(name.to_sym, nil, label:)
219
+ end
220
+
221
+ # DSL method
222
+ # Adds multiple sorts at once, sharing the same kwargs.
223
+ def sorts(*names, **)
224
+ names.each { |name| sort(name, **) }
225
+ end
226
+
227
+ # This method must be called before the data is read for the first time. It makes the data fit for display. Only call it once.
228
+ def process_data!(controller)
229
+ fail('Data was already processed!') if @processed_data
230
+ # Filtering
231
+ if filtering_enabled?
232
+ @q = @data.ransack(controller.params[param_name(:q)], auth_object: controller.current_ability, search_key: param_name(:q))
233
+ filtered_data = @q.result.accessible_by(controller.current_ability)
234
+ else
235
+ filtered_data = @data
236
+ end
237
+ # Pagination
238
+ if pagination_enabled?
239
+ @page = controller.params[param_name('page')].presence&.to_i || 1
240
+ @pagination_offset = (@page - 1) * @results_per_page
241
+ @total_pages = (filtered_data.count.to_f / @results_per_page).ceil
242
+ if @pagination_offset < 0 || @pagination_offset >= filtered_data.count # out of bounds check
243
+ @page = 1
244
+ @pagination_offset = 0
245
+ end
246
+ @processed_data = filtered_data.offset(@pagination_offset).limit(@results_per_page)
247
+ else
248
+ @processed_data = filtered_data
249
+ end
250
+ # Apply skips to configs
251
+ # Exclude columns that are skipped or the user is not allowed to display
252
+ @columns.select! do |col, _|
253
+ @skipped_columns.exclude?(col[:name]) && controller.current_ability.permitted_attributes(:index, data_class).include?(col[:name])
254
+ end
255
+ # Exclude skipped filters
256
+ @filters.select! { |filter, _| @skipped_filters.exclude?(filter[:name]) }
257
+ end
258
+
259
+ setup do
260
+ label(:all) { I18n.t(family_name.humanize) }
261
+
262
+ load_data do
263
+ # Prepare raw data. This should not be used directly - processed_data should be used instead
264
+ @data = data_class.accessible_by(controller.current_ability)
265
+ end
266
+
267
+ # Default row actions (use override or skip_row_action to prevent)
268
+ row_action(:show)
269
+ row_action(:edit)
270
+ row_action(:destroy)
271
+
272
+ before_render do
273
+ process_data!(controller)
274
+ end
275
+
276
+ content do
277
+ content :filter if (sorting_in_filter_enabled? && @sorts.any?) || (filtering_enabled? && @filters.any?)
278
+ content :sorting_links if sorting_links_enabled? && @sorts.any?
279
+ content :data
280
+ content :pagination if pagination_enabled? && @total_pages > 1
281
+ end
282
+
283
+ content :filter, hidden: true do
284
+ div class: 'list-filter-container' do
285
+ form_html = search_form_for @q, url: url_for, as: param_name(:q) do |f|
286
+ # Sorting in filter
287
+ if sorting_in_filter_enabled? && @sorts.any?
288
+ div class: 'list-sorting-in-filter' do
289
+ div f.label(:s, I18n.t('compony.components.index.sorting'))
290
+ div f.select(:s, sorting_in_filter_select_opts, { include_blank: true, selected: params.dig(param_name(:q), :s) })
291
+ end
292
+ end
293
+ # Filters
294
+ if filtering_enabled? && @filters.any?
295
+ div class: 'list-filters' do
296
+ strong I18n.t('compony.components.index.filters')
297
+ @filters.each do |filter|
298
+ div do
299
+ instance_exec(f, &filter[:payload])
300
+ end
301
+ end
302
+ end
303
+ end
304
+ # Submit button
305
+ div class: 'list-filter-button-container' do
306
+ concat f.submit
307
+ end
308
+ end
309
+ concat form_html
310
+ end
311
+ end
312
+
313
+ content :sorting_links, hidden: true do
314
+ div do
315
+ strong I18n.t('compony.components.index.sorting')
316
+ @sorts.each do |sort|
317
+ span sort_link(@q, sort[:name], sort[:label])
318
+ end
319
+ end
320
+ end
321
+
322
+ content :data, hidden: true do
323
+ table class: 'list-data-table' do
324
+ thead do
325
+ tr do
326
+ @columns.each do |column|
327
+ th column[:label], class: 'list-data-label'
328
+ end
329
+ if @row_actions.any? { |row_action| @skipped_row_actions.exclude?(row_action[:name]) }
330
+ th I18n.t('compony.components.index.actions'), class: 'list-actions-label'
331
+ end
332
+ end
333
+ end
334
+ tbody do
335
+ @processed_data.each do |record|
336
+ tr do
337
+ @columns.each do |column|
338
+ td class: column[:class] do
339
+ instance_exec(record, &column[:payload])
340
+ end
341
+ end
342
+ rendered_row_actions = @row_actions.map do |row_action|
343
+ next if @skipped_row_actions.include?(row_action[:name])
344
+ next instance_exec(record, &row_action[:payload])
345
+ end.compact
346
+ if rendered_row_actions.any?
347
+ td do
348
+ rendered_row_actions.each do |row_action_html|
349
+ concat row_action_html if row_action_html
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
358
+
359
+ content :pagination, hidden: true do
360
+ current_params = request.GET.dup
361
+ div class: 'list-pagination-wrapper' do
362
+ unless @page == 1
363
+ span link_to(I18n.t('compony.components.index.pagination.first'), current_params.merge(param_name('page') =>1)),
364
+ class: 'list-pagination list-pagination-first'
365
+ span link_to(I18n.t('compony.components.index.pagination.previous'), current_params.merge(param_name('page') =>@page - 1)),
366
+ class: 'list-pagination list--pagination-previous'
367
+ end
368
+ span @page, class: 'list-pagination list--pagination-current'
369
+ unless @page == @total_pages
370
+ span link_to(I18n.t('compony.components.index.pagination.next'), current_params.merge(param_name('page') =>@page + 1),
371
+ class: 'list-pagination list--pagination-next')
372
+ span link_to(I18n.t('compony.components.index.pagination.last'), current_params.merge(param_name('page') =>@total_pages),
373
+ class: 'list-pagination list--pagination-last')
374
+ end
375
+ end
376
+ end
377
+ end
378
+
379
+ protected
380
+
381
+ # Returns whether filtering is possible and wanted in general (regardless of whether there are any filters defined)
382
+ def filtering_enabled?
383
+ @filtering && defined?(Ransack)
384
+ end
385
+
386
+ # Returns whether sorting is possible and wanted in general (regardless of whether there are any sorts defined)
387
+ def sorting_enabled?
388
+ (@sorting_in_filter || @sorting_links) && defined?(Ransack)
389
+ end
390
+
391
+ # Returns whether sorting in filter is possible and wanted in general (regardless of whether there are any sorts defined)
392
+ def sorting_in_filter_enabled?
393
+ sorting_enabled? && @sorting_in_filter
394
+ end
395
+
396
+ # Returns whether generating sorting links is possible and wanted in general (regardless of whether there are any sorts defined)
397
+ def sorting_links_enabled?
398
+ sorting_enabled? && @sorting_links
399
+ end
400
+
401
+ # Returns whether pagination is enabled (regardless of whether there is more than one page)
402
+ def pagination_enabled?
403
+ @pagination
404
+ end
405
+
406
+ # Returns the select options for sorting suitable for passing in a `f.select`. Used in sorting-in-filter feature. Useful for custom subclasses of List.
407
+ def sorting_in_filter_select_opts
408
+ @sorts.flat_map do |sort|
409
+ %w[asc desc].map do |order|
410
+ label = "#{sort[:label]} #{order == 'asc' ? '↑' : '↓'}"
411
+ value = "#{sort[:name]} #{order}"
412
+ [label, value]
413
+ end
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,116 @@
1
+ module Compony
2
+ module Components
3
+ # @api description
4
+ # This component is used for the Rails show paradigm.
5
+ class Show < Compony::Component
6
+ include Compony::ComponentMixins::Resourceful
7
+
8
+ setup do
9
+ standalone path: "/#{family_name}/:id", constraints: { id: /\d*/ } do
10
+ verb :get do
11
+ authorize { can?(:show, @data) }
12
+ end
13
+ end
14
+
15
+ label(:long) { |data| data.label } # rubocop:disable Style/SymbolProc
16
+ label(:short) { |_| I18n.t('compony.components.show.label.short') }
17
+ icon { :eye }
18
+
19
+ action :back_to_owner do
20
+ next if data_class.owner_model_attr.blank?
21
+ Compony.button(:show, @data.send(data_class.owner_model_attr), icon: :'arrow-left', color: :secondary, label: I18n.t('compony.back'))
22
+ end
23
+
24
+ if Compony.comp_class_for(:edit, family_cst)
25
+ action :edit do
26
+ Compony.button(:edit, @data, label_opts: { format: :short })
27
+ end
28
+ end
29
+
30
+ if Compony.comp_class_for(:destroy, family_cst)
31
+ action :destroy do
32
+ Compony.button(:destroy, @data, label_opts: { format: :short })
33
+ end
34
+ end
35
+
36
+ content :label do
37
+ h2 component.label
38
+ end
39
+
40
+ content do
41
+ content :data # Overwrite the main content block to wrap the data content block into e.g. a bootstrap card etc.
42
+ end
43
+
44
+ content :data, hidden: true do
45
+ all_field_columns(@data) if @columns.none? # Default to showing everything
46
+
47
+ table do
48
+ thead do
49
+ @columns.each do |column|
50
+ value = instance_exec(@data, &column[:payload])
51
+ next if value.nil?
52
+ tr do
53
+ td column[:label]
54
+ td value, class: column[:class]
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # @param skip_columns [Array] Column names to be skipped in the case where this component is nested and therefore instanciated by a parent comp.
63
+ def initialize(*, skip_columns: [], **)
64
+ @columns = NaturalOrdering.new
65
+ @skipped_columns = skip_columns.map(&:to_sym)
66
+ super(*, **)
67
+ end
68
+
69
+ # Adds a column. The term column is for consistency with the List component and columns are typically model fields / attributes.
70
+ # @param name [String,Symbol] The technical name of the attribute this column will be for.
71
+ # @param label [nil,String] The human displayed label for this attribte. If nil, will consider `name` to be a field name and load the field's label.
72
+ # @param class [nil,String] Extra CSS classes for the column's value.
73
+ # @param link_opts [Hash] Options to pass to the `link_to` helper. Only used in the case of a field column that will produce a link (e.g. accociation).
74
+ # @param link_to_component [Symbol] In the case a link is produced (e.g. association), defines the component the link points to. Detaults to `:show`.
75
+ # @param block [Block] Custom code to run in order to provide the displayed value. Will be given the current record.
76
+ def column(name, label: nil, class: nil, link_opts: {}, link_to_component: :show, **, &block)
77
+ name = name.to_sym
78
+ unless block_given?
79
+ # Assume field column
80
+ field = data_class.fields[name] || fail("Field #{name.inspect} was not found for data class #{data_class}")
81
+ block = proc do |record|
82
+ if controller.current_ability.permitted_attributes(:show, record).include?(field.name.to_sym)
83
+ next field.value_for(record, link_to_component:, controller:, link_opts:).to_s
84
+ else
85
+ Rails.logger.debug { "Skipping show col #{field.name.inspect} because the current user is not allowed to perform show on #{data}." }
86
+ nil
87
+ end
88
+ end
89
+ end
90
+ @columns.natural_push(name, block, label: label || field.label, class:, **)
91
+ end
92
+
93
+ # DSL method
94
+ # Adds multiple columns that have identical kwargs, e.g. `class` (see `column`). Typically only used for bulk-adding model fields.
95
+ # @param col_names [String] Names of the fields in `@data` that are to be added as attributes.
96
+ def columns(*col_names, **)
97
+ col_names.each { |col_name| column(col_name, **) }
98
+ end
99
+
100
+ # DSL method
101
+ # Marks a column as skipped. Useful only when inheriting from a component that provides too many columns.
102
+ # When nesting components and a column of a child `Show` component is to be skipped, use the constructor's `skip_columns` argument instead.
103
+ # @param name [String] Name of the column to be skipped.
104
+ def skip_column(name)
105
+ @skipped_columns << name.to_sym
106
+ end
107
+
108
+ # DSL method
109
+ # Goes through the fields of the given data and adds a field column for every field found.
110
+ # @param data [ApplicationModel] Compony-enriched model that will be queried for fields.
111
+ def all_field_columns(data)
112
+ data.fields.each_key { |field_name| column(field_name) }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -20,6 +20,19 @@ module Compony
20
20
  end
21
21
  return form.input name || @name, as: :hidden, **input_opts
22
22
  end
23
+
24
+ def ransack_filter_name
25
+ :"#{@name}_eq"
26
+ end
27
+
28
+ def ransack_filter_input(form, **input_opts)
29
+ form.select(
30
+ ransack_filter_name,
31
+ self.class.collect(@model_class.anchormodel_attributes[@name].anchormodel_class.all),
32
+ { include_blank: true },
33
+ { class: input_opts[:filter_select_class] }
34
+ )
35
+ end
23
36
  end
24
37
  end
25
38
  end
@@ -52,6 +52,18 @@ module Compony
52
52
  return form.input name || @name, as: :hidden, **input_opts
53
53
  end
54
54
 
55
+ # Used in list component
56
+ # Given a column name, returns the most suitable ransack filter name
57
+ def ransack_filter_name
58
+ :"#{@name}_cont"
59
+ end
60
+
61
+ # Used in list component
62
+ # Given a ransack search form, returns a suitable search input
63
+ def ransack_filter_input(form, **input_opts)
64
+ form.search_field(ransack_filter_name, class: input_opts[:filter_input_class])
65
+ end
66
+
55
67
  protected
56
68
 
57
69
  # If given a scalar, calls the block on the scalar. If given a list, calls the block on every member and joins the result with ",".
@@ -4,6 +4,19 @@ module Compony
4
4
  def value_for(data, controller: nil, **_)
5
5
  return transform_and_join(data.send(@name), controller:) { |el| el.nil? ? nil : I18n.t("compony.boolean.#{el}") }
6
6
  end
7
+
8
+ def ransack_filter_name
9
+ :"#{@name}_eq"
10
+ end
11
+
12
+ def ransack_filter_input(form, **input_opts)
13
+ form.select(
14
+ ransack_filter_name,
15
+ [['', nil], [I18n.t('compony.boolean.true'), true], [I18n.t('compony.boolean.false'), false]],
16
+ {},
17
+ { class: input_opts[:filter_select_class] }
18
+ )
19
+ end
7
20
  end
8
21
  end
9
22
  end
@@ -1,6 +1,9 @@
1
1
  module Compony
2
2
  module ModelFields
3
3
  class Integer < Base
4
+ def ransack_filter_name
5
+ :"#{@name}_eq"
6
+ end
4
7
  end
5
8
  end
6
9
  end
@@ -81,6 +81,11 @@ module Compony
81
81
  end
82
82
  self.autodetect_feasibilities_completed = true
83
83
  end
84
+
85
+ # Provides Ransack defaults (auth_object must be a cancancan ability)
86
+ def ransackable_attributes(auth_object)
87
+ auth_object.permitted_attributes(:read, self).map(&:to_s)
88
+ end
84
89
  end
85
90
 
86
91
  # Retrieves feasibility for the given instance, returning a boolean indicating whether the action is feasibly.
data/lib/compony.rb CHANGED
@@ -307,6 +307,9 @@ require 'compony/component_mixins/default/labelling'
307
307
  require 'compony/component_mixins/resourceful'
308
308
  require 'compony/component'
309
309
  require 'compony/components/button'
310
+ require 'compony/components/index'
311
+ require 'compony/components/list'
312
+ require 'compony/components/show'
310
313
  require 'compony/components/form'
311
314
  require 'compony/components/with_form'
312
315
  require 'compony/components/new'