compony 0.5.9 → 0.6.1

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 +9 -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 +2820 -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 +413 -96
  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 +428 -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 Mon Sep 8 14:24:21 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,428 @@
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
+ # @param default_sorting [String] Default sorting (only relevant for ransack based sorting)
20
+ def initialize(*,
21
+ skip_pagination: false,
22
+ results_per_page: 20,
23
+ skip_filtering: false,
24
+ skip_sorting: false,
25
+ skip_sorting_in_filter: false,
26
+ skip_sorting_links: false,
27
+ skip_columns: [],
28
+ skip_row_actions: [],
29
+ skip_filters: [],
30
+ default_sorting: 'id asc',
31
+ **)
32
+ @pagination = !skip_pagination
33
+ @results_per_page = results_per_page
34
+ @filtering = !skip_filtering
35
+ @sorting_in_filter = !skip_sorting && !skip_sorting_in_filter
36
+ @sorting_links = !skip_sorting && !skip_sorting_links
37
+ @columns = Compony::NaturalOrdering.new
38
+ @row_actions = Compony::NaturalOrdering.new
39
+ @skipped_columns = skip_columns.map(&:to_sym)
40
+ @skipped_row_actions = skip_row_actions.map(&:to_sym)
41
+ @filters = Compony::NaturalOrdering.new
42
+ @sorts = Compony::NaturalOrdering.new
43
+ @skipped_filters = skip_filters.map(&:to_sym)
44
+ @default_sorting = default_sorting
45
+ @filter_label_class = 'list-filter-label'
46
+ @filter_input_class = 'list-filter-input'
47
+ @filter_select_class = 'list-filter-select'
48
+ @filter_item_wrapper_class = nil
49
+ super(*, **)
50
+ end
51
+
52
+ # DSL method
53
+ # Disables pagination (caution: all records will be loaded).
54
+ def skip_pagination!
55
+ @pagination = false
56
+ end
57
+
58
+ # DSL method
59
+ # In case pagination is active, defines the amount of records to display per page.
60
+ def results_per_page(new_results_per_page)
61
+ @results_per_page = new_results_per_page
62
+ end
63
+
64
+ # DSL method
65
+ # Disables filtering entirely (sorting is independent of this setting).
66
+ def skip_filtering!
67
+ @filtering = false
68
+ end
69
+
70
+ # DSL method
71
+ # Disables sorting entirely (both links and sorting input in filter).
72
+ def skip_sorting!
73
+ @sorting_in_filter = false
74
+ @sorting_links = false
75
+ end
76
+
77
+ # DSL method
78
+ # Disables sorting in filter.
79
+ def skip_sorting_in_filter!
80
+ @sorting_in_filter = false
81
+ end
82
+
83
+ # DSL method
84
+ # Disables sorting links.
85
+ def skip_sorting_links!
86
+ @sorting_links = false
87
+ end
88
+
89
+ # DSL method
90
+ # Overrides the default sorting
91
+ def default_sorting(new_default_sorting)
92
+ @default_sorting = new_default_sorting
93
+ end
94
+
95
+ # DSL method
96
+ # Sets the CSS class attribute for form label elements in filters.
97
+ # @param class_str [String] Space-separated list of CSS classes
98
+ def filter_label_class(class_str)
99
+ @filter_label_class = class_str
100
+ end
101
+
102
+ # DSL method
103
+ # Sets the CSS class attribute for string form inputs in filters.
104
+ # @param class_str [String] Space-separated list of CSS classes
105
+ def filter_input_class(class_str)
106
+ @filter_input_class = class_str
107
+ end
108
+
109
+ # DSL method
110
+ # Sets the CSS class attribute for form select inputs in filters.
111
+ # @param class_str [String] Space-separated list of CSS classes
112
+ def filter_select_class(class_str)
113
+ @filter_select_class = class_str
114
+ end
115
+
116
+ # DSL method
117
+ # Sets the CSS class attribute for the div that wraps input-related elements in filters (inputs, selects, labels).
118
+ # @param class_str [String] Space-separated list of CSS classes
119
+ def filter_item_wrapper_class(class_str)
120
+ @filter_item_wrapper_class = class_str
121
+ end
122
+
123
+ # DSL method
124
+ # Adds a new column to the list. If `name` corresponds to that of a field, everything is auto-inferred.
125
+ # 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.
126
+ # Please note that the column is only shown if the current user has permission to index the attribute.
127
+ # @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.
128
+ # @param label [String] Title of the column to be displayed in the table header.
129
+ # @param class [String] Space-separated list of CSS classes for each cell
130
+ # @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`.
131
+ # @param block [Block] Custom block, given the record and instance-execed in the context of the cell for every row.
132
+ def column(name, label: nil, class: nil, link_opts: {}, **, &block)
133
+ name = name.to_sym
134
+ unless block_given?
135
+ # Assume field column
136
+ field = data_class.fields[name] || fail("Field #{name.inspect} was not found for data class #{data_class}")
137
+ block = proc do |record|
138
+ if controller.current_ability.permitted_attributes(:index, record).include?(field.name.to_sym)
139
+ next field.value_for(record, link_to_component: :show, controller:, link_opts:).to_s
140
+ end
141
+ end
142
+ end
143
+ @columns.natural_push(name, block, label: label || field.label, class:, **)
144
+ end
145
+
146
+ # DSL method
147
+ # Adds multiple columns at once, sharing the same kwargs.
148
+ def columns(*col_names, **)
149
+ col_names.each { |col_name| column(col_name, **) }
150
+ end
151
+
152
+ # DSL method
153
+ # Marks a single column as skipped. It will not be displayed, even if it is defined.
154
+ # @param name [Symbol,String] Name of the column to be skipped.
155
+ def skip_column(name)
156
+ @skipped_columns << name.to_sym
157
+ end
158
+
159
+ # DSL method
160
+ # Adds a row action. The very last col provides actions such as :show, :edit or :destroy. Use this method to add your own.
161
+ # 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.
162
+ # 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.
163
+ # @param name [Symbol, String] The name of the action (e.g. :edit).
164
+ # @param button_opts [Hash] Only relevant in case of an auto-generated row action, this allows to configure the generated button.
165
+ # @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.
166
+ def row_action(name, button_opts: {}, **, &block)
167
+ name = name.to_sym
168
+ unless block_given?
169
+ block = proc do |record|
170
+ next if Compony.comp_class_for(name, record).nil?
171
+ compony_button(name, record, **button_opts)
172
+ end
173
+ end
174
+ @row_actions.natural_push(name, block, **)
175
+ end
176
+
177
+ # DSL method
178
+ # Marks a single row action as skipped. It will not be displayed, even if it is defined.
179
+ # @param name [Symbol,String] Name of the row action to be skipped.
180
+ def skip_row_action(name)
181
+ @skipped_row_actions << name.to_sym
182
+ end
183
+
184
+ # DSL method
185
+ # Adds a ransack filter. If `name` is the name of an existing model field, the filter is auto-generated.
186
+ # If `name` is a valid Ransack search string (e.g. `id_eq`), all you need to pass is `name` and `label`.
187
+ # To create a fully custom filter, pass `name` and `block`. The block will be given the Ransack search form and should return HTML.
188
+ # @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).
189
+ # @param label [String] The text to use in the input's label.
190
+ # @param block [Block] Custom block that will be given the Ransack search form and should produce a label and a search input.
191
+ def filter(name, label: nil, **, &block)
192
+ name = name.to_sym
193
+ unless block_given?
194
+ field = data_class.fields[name]
195
+ block ||= proc do |f|
196
+ label ||= field.label if field
197
+ fail("You must provide a label to filter #{name.inspect}.") unless label
198
+
199
+ if field
200
+ filter_name = field.ransack_filter_name
201
+ filter_input_html = capture { field.ransack_filter_input(f, filter_input_class: @filter_input_class, filter_select_class: @filter_select_class) }
202
+ else
203
+ filter_name = name
204
+ filter_input_html = capture { f.search_field(filter_name, class: @filter_input_class) }
205
+ end
206
+ div tag.label(label, for: filter_name, class: @filter_label_class), class: @filter_item_wrapper_class
207
+ div filter_input_html, class: @filter_item_wrapper_class
208
+ end
209
+ end
210
+
211
+ @filters.natural_push(name, block, **)
212
+ end
213
+
214
+ # DSL method
215
+ # Adds multiple filters at once, sharing the same kwargs.
216
+ def filters(*filter_names, **)
217
+ filter_names.each { |filter_name| filter(filter_name, **) }
218
+ end
219
+
220
+ # DSL method
221
+ # Adds a sorting criterion that will be processed by ransack. `data_class` must be sortable by this criterion. See Ransack's sorting for constraints.
222
+ # 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.
223
+ # @param name [Symbol, String] Sorting criteria, e.g. `:id` or `:label`.
224
+ # @param label [String] Label of the sorting link / entries.
225
+ def sort(name, label: nil)
226
+ label ||= data_class.fields[name].label
227
+ @sorts.natural_push(name.to_sym, nil, label:)
228
+ end
229
+
230
+ # DSL method
231
+ # Adds multiple sorts at once, sharing the same kwargs.
232
+ def sorts(*names, **)
233
+ names.each { |name| sort(name, **) }
234
+ end
235
+
236
+ # 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.
237
+ def process_data!(controller)
238
+ fail('Data was already processed!') if @processed_data
239
+ # Filtering
240
+ if filtering_enabled?
241
+ @q = @data.ransack(controller.params[param_name(:q)], auth_object: controller.current_ability, search_key: param_name(:q))
242
+ @q.sorts = @default_sorting if @q.sorts.empty?
243
+ filtered_data = @q.result.accessible_by(controller.current_ability)
244
+ else
245
+ filtered_data = @data
246
+ end
247
+ # Pagination
248
+ if pagination_enabled?
249
+ @page = controller.params[param_name('page')].presence&.to_i || 1
250
+ @pagination_offset = (@page - 1) * @results_per_page
251
+ @total_pages = (filtered_data.count.to_f / @results_per_page).ceil
252
+ if @pagination_offset < 0 || @pagination_offset >= filtered_data.count # out of bounds check
253
+ @page = 1
254
+ @pagination_offset = 0
255
+ end
256
+ @processed_data = filtered_data.offset(@pagination_offset).limit(@results_per_page)
257
+ else
258
+ @processed_data = filtered_data
259
+ end
260
+ # Apply skips to configs
261
+ # Exclude columns that are skipped or the user is not allowed to display
262
+ @columns.select! do |col, _|
263
+ @skipped_columns.exclude?(col[:name]) && controller.current_ability.permitted_attributes(:index, data_class).include?(col[:name])
264
+ end
265
+ # Exclude skipped filters
266
+ @filters.select! { |filter, _| @skipped_filters.exclude?(filter[:name]) }
267
+ end
268
+
269
+ setup do
270
+ label(:all) { I18n.t(family_name.humanize) }
271
+
272
+ load_data do
273
+ # Prepare raw data. This should not be used directly - processed_data should be used instead
274
+ @data = data_class.accessible_by(controller.current_ability)
275
+ end
276
+
277
+ # Default row actions (use override or skip_row_action to prevent)
278
+ row_action(:show)
279
+ row_action(:edit)
280
+ row_action(:destroy)
281
+
282
+ before_render do
283
+ process_data!(controller)
284
+ end
285
+
286
+ content do
287
+ content :filter if (sorting_in_filter_enabled? && @sorts.any?) || (filtering_enabled? && @filters.any?)
288
+ content :sorting_links if sorting_links_enabled? && @sorts.any?
289
+ content :data
290
+ content :pagination if pagination_enabled? && @total_pages > 1
291
+ end
292
+
293
+ content :filter, hidden: true do
294
+ div class: 'list-filter-container' do
295
+ form_html = search_form_for @q, url: url_for, as: param_name(:q) do |f|
296
+ # Sorting in filter
297
+ if sorting_in_filter_enabled? && @sorts.any?
298
+ div class: 'list-sorting-in-filter' do
299
+ div f.label(:s, I18n.t('compony.components.index.sorting'))
300
+ div f.select(:s, sorting_in_filter_select_opts, { include_blank: true, selected: params.dig(param_name(:q), :s) })
301
+ end
302
+ end
303
+ # Filters
304
+ if filtering_enabled? && @filters.any?
305
+ div class: 'list-filters' do
306
+ strong I18n.t('compony.components.index.filters')
307
+ @filters.each do |filter|
308
+ div do
309
+ instance_exec(f, &filter[:payload])
310
+ end
311
+ end
312
+ end
313
+ end
314
+ # Submit button
315
+ div class: 'list-filter-button-container' do
316
+ concat f.submit
317
+ end
318
+ end
319
+ concat form_html
320
+ end
321
+ end
322
+
323
+ content :sorting_links, hidden: true do
324
+ div do
325
+ strong I18n.t('compony.components.index.sorting')
326
+ @sorts.each do |sort|
327
+ span sort_link(@q, sort[:name], sort[:label])
328
+ end
329
+ end
330
+ end
331
+
332
+ content :data, hidden: true do
333
+ table class: 'list-data-table' do
334
+ thead do
335
+ tr do
336
+ @columns.each do |column|
337
+ th column[:label], class: 'list-data-label'
338
+ end
339
+ if @row_actions.any? { |row_action| @skipped_row_actions.exclude?(row_action[:name]) }
340
+ th I18n.t('compony.components.index.actions'), class: 'list-actions-label'
341
+ end
342
+ end
343
+ end
344
+ tbody do
345
+ @processed_data.each do |record|
346
+ tr do
347
+ @columns.each do |column|
348
+ td class: column[:class] do
349
+ instance_exec(record, &column[:payload])
350
+ end
351
+ end
352
+ rendered_row_actions = @row_actions.map do |row_action|
353
+ next if @skipped_row_actions.include?(row_action[:name])
354
+ next instance_exec(record, &row_action[:payload])
355
+ end.compact
356
+ if rendered_row_actions.any?
357
+ td do
358
+ rendered_row_actions.each do |row_action_html|
359
+ concat row_action_html if row_action_html
360
+ end
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end
368
+
369
+ content :pagination, hidden: true do
370
+ current_params = request.GET.dup
371
+ div class: 'list-pagination-wrapper' do
372
+ unless @page == 1
373
+ span link_to(I18n.t('compony.components.index.pagination.first'), current_params.merge(param_name('page') =>1)),
374
+ class: 'list-pagination list-pagination-first'
375
+ span link_to(I18n.t('compony.components.index.pagination.previous'), current_params.merge(param_name('page') =>@page - 1)),
376
+ class: 'list-pagination list--pagination-previous'
377
+ end
378
+ span @page, class: 'list-pagination list--pagination-current'
379
+ unless @page == @total_pages
380
+ span link_to(I18n.t('compony.components.index.pagination.next'), current_params.merge(param_name('page') =>@page + 1),
381
+ class: 'list-pagination list--pagination-next')
382
+ span link_to(I18n.t('compony.components.index.pagination.last'), current_params.merge(param_name('page') =>@total_pages),
383
+ class: 'list-pagination list--pagination-last')
384
+ end
385
+ end
386
+ end
387
+ end
388
+
389
+ protected
390
+
391
+ # Returns whether filtering is possible and wanted in general (regardless of whether there are any filters defined)
392
+ def filtering_enabled?
393
+ @filtering && defined?(Ransack)
394
+ end
395
+
396
+ # Returns whether sorting is possible and wanted in general (regardless of whether there are any sorts defined)
397
+ def sorting_enabled?
398
+ (@sorting_in_filter || @sorting_links) && defined?(Ransack)
399
+ end
400
+
401
+ # Returns whether sorting in filter is possible and wanted in general (regardless of whether there are any sorts defined)
402
+ def sorting_in_filter_enabled?
403
+ sorting_enabled? && @sorting_in_filter
404
+ end
405
+
406
+ # Returns whether generating sorting links is possible and wanted in general (regardless of whether there are any sorts defined)
407
+ def sorting_links_enabled?
408
+ sorting_enabled? && @sorting_links
409
+ end
410
+
411
+ # Returns whether pagination is enabled (regardless of whether there is more than one page)
412
+ def pagination_enabled?
413
+ @pagination
414
+ end
415
+
416
+ # 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.
417
+ def sorting_in_filter_select_opts
418
+ @sorts.flat_map do |sort|
419
+ %w[asc desc].map do |order|
420
+ label = "#{sort[:label]} #{order == 'asc' ? '↑' : '↓'}"
421
+ value = "#{sort[:name]} #{order}"
422
+ [label, value]
423
+ end
424
+ end
425
+ end
426
+ end
427
+ end
428
+ 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.