gestart-admino 1.0.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +29 -0
  3. data/.gitignore +20 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +91 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +716 -0
  9. data/Rakefile +6 -0
  10. data/gestart-admino.gemspec +36 -0
  11. data/lib/admino/action_view_extension.rb +35 -0
  12. data/lib/admino/query/base.rb +124 -0
  13. data/lib/admino/query/base_presenter.rb +27 -0
  14. data/lib/admino/query/builder.rb +24 -0
  15. data/lib/admino/query/configuration.rb +100 -0
  16. data/lib/admino/query/dsl.rb +38 -0
  17. data/lib/admino/query/filter_group.rb +73 -0
  18. data/lib/admino/query/filter_group_presenter.rb +85 -0
  19. data/lib/admino/query/scope_presenter.rb +17 -0
  20. data/lib/admino/query/search_field.rb +43 -0
  21. data/lib/admino/query/sorting.rb +74 -0
  22. data/lib/admino/query/sorting_presenter.rb +60 -0
  23. data/lib/admino/query.rb +15 -0
  24. data/lib/admino/table/head_row.rb +86 -0
  25. data/lib/admino/table/presenter.rb +122 -0
  26. data/lib/admino/table/resource_row.rb +145 -0
  27. data/lib/admino/table/row.rb +56 -0
  28. data/lib/admino/table.rb +7 -0
  29. data/lib/admino/version.rb +3 -0
  30. data/lib/admino.rb +14 -0
  31. data/lib/gestart-admino.rb +1 -0
  32. data/spec/admino/integration_spec.rb +63 -0
  33. data/spec/admino/query/base_presenter_spec.rb +59 -0
  34. data/spec/admino/query/base_spec.rb +192 -0
  35. data/spec/admino/query/dsl_spec.rb +52 -0
  36. data/spec/admino/query/filter_group_presenter_spec.rb +182 -0
  37. data/spec/admino/query/filter_group_spec.rb +133 -0
  38. data/spec/admino/query/search_field_spec.rb +66 -0
  39. data/spec/admino/query/sorting_presenter_spec.rb +206 -0
  40. data/spec/admino/query/sorting_spec.rb +137 -0
  41. data/spec/admino/table/head_row_spec.rb +158 -0
  42. data/spec/admino/table/presenter_spec.rb +180 -0
  43. data/spec/admino/table/resource_row_spec.rb +225 -0
  44. data/spec/admino/table/row_spec.rb +76 -0
  45. data/spec/spec_helper.rb +93 -0
  46. metadata +267 -0
data/README.md ADDED
@@ -0,0 +1,716 @@
1
+ ![Admino Logo](https://raw.github.com/cantierecreativo/admino/master/logo.jpg)
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/admino.png)](http://badge.fury.io/rb/admino)
4
+ [![Build Status](https://travis-ci.org/cantierecreativo/admino.png?branch=v0.0.1)](https://travis-ci.org/cantierecreativo/admino)
5
+ [![Coverage Status](https://coveralls.io/repos/cantierecreativo/admino/badge.png?branch=master)](https://coveralls.io/r/cantierecreativo/admino?branch=master)
6
+ [![Code Climate](https://codeclimate.com/github/cantierecreativo/admino.png)](https://codeclimate.com/github/cantierecreativo/admino)
7
+
8
+ A minimal, object-oriented solution to generate Rails administrative index views. Through query objects and presenters, it features a customizable table generator and search forms with filtering/sorting.
9
+
10
+ ## The philosophy behind it
11
+
12
+ The Rails ecosystem has many [full-fledged solutions to generate administrative interfaces](https://www.ruby-toolbox.com/categories/rails_admin_interfaces).
13
+
14
+ Although these tools are very handy to bootstrap a project quickly, they all obey the [80%-20% rule](http://en.wikipedia.org/wiki/Pareto_principle) and tend to be very invasive, often mixing up different concerns on a single responsibility level, thus making tests unbelievably difficult to setup and write.
15
+
16
+ A time comes when these all-encompassing tools get in the way. And that will be the moment where all the cumulated saved time will be wasted to solve a single, trivial problem with ugly workarounds and [epic facepalms](http://i.imgur.com/ghKDGyv.jpg).
17
+
18
+ So yes, if you're starting a small, short-lived project, go ahead with them, it will be fine! If you're building something that's more valuable or is meant to last longer, there are better alternatives.
19
+
20
+ ### A modular approach to the problem
21
+
22
+ The great thing is that you don't need to write a lot of code to get a more maintainable and modular administrative area.
23
+ Gems like [Inherited Resources](https://github.com/josevalim/inherited_resources) and [Simple Form](https://github.com/plataformatec/simple_form), combined with [Rails 3.1+ template-inheritance](http://railscasts.com/episodes/269-template-inheritance) already give you ~90% of the time-saving features and the same super-DRY, declarative code that administrative interfaces offer, but with a far more relaxed contract.
24
+
25
+ If a particular controller or view needs something different from the standard CRUD/REST treatment, you can just avoid using those gems in that specific context, and fall back to standard Rails code. No workarounds, no facepalms. It seems easy, right? It is.
26
+
27
+ So what about Admino? Well, it complements the above-mentioned gems, giving you the the missing ~10%: a fast way to generate administrative index views.
28
+
29
+ ## Demo
30
+
31
+ To better illustrate how to create a 100%-custom, super-DRY administrative interface using Admino and the aforementioned gems, we prepared a [repo with a sample Rails project](https://github.com/cantierecreativo/admino-example) you can take a look. The app is browsable at [http://admino-example.herokuapp.com](http://admino-example.herokuapp.com), and features a Bootstrap 3 theme.
32
+
33
+ ## Installation
34
+
35
+ Add this line to your application's Gemfile:
36
+
37
+ gem 'admino'
38
+
39
+ And then execute:
40
+
41
+ $ bundle
42
+
43
+ ## Admino::Query::Base
44
+
45
+ `Admino::Query::Base` implements the [Query object](http://martinfowler.com/eaaCatalog/queryObject.html) pattern, that is, an object responsible for returning a result set (ie. an `ActiveRecord::Relation`) based on business rules.
46
+
47
+ Given a `Task` model, we can generate a `TasksQuery` query object subclassing `Admino::Query::Base`:
48
+
49
+ ```ruby
50
+ class TasksQuery < Admino::Query::Base
51
+ end
52
+ ```
53
+
54
+ Each query object gets initialized with a hash of params, and features a `#scope` method that returns the filtered/sorted result set. As you may have guessed, query objects can be great companions to index actions:
55
+
56
+ ```ruby
57
+ class TasksController < ApplicationController
58
+ def index
59
+ @query = TasksQuery.new(params)
60
+ @tasks = @query.scope
61
+ end
62
+ end
63
+ ```
64
+
65
+ ### Building the query itself
66
+
67
+ You can specify how a `TaskQuery` must build a result set through a simple DSL.
68
+
69
+ #### `starting_scope`
70
+
71
+ The `starting_scope` method is in charge of defining the scope that will start the filtering/ordering chain:
72
+
73
+ ```ruby
74
+ class TasksQuery < Admino::Query::Base
75
+ starting_scope { Task.all }
76
+ end
77
+
78
+ Task.create(title: 'Low priority task')
79
+
80
+ TaskQuery.new.scope.count # => 1
81
+ ```
82
+
83
+ #### `search_field`
84
+
85
+ Once you define the following field:
86
+
87
+ ```ruby
88
+ class TasksQuery < Admino::Query::Base
89
+ # ...
90
+ search_field :title_matches
91
+ end
92
+ ```
93
+
94
+ The `#scope` method will check the presence of the `params[:query][:title_matches]` key. If it finds it, it will augment the query with a named scope called `:title_matches`, expected to be found within the `Task` model. The scope needs to accept an argument.
95
+
96
+ ```ruby
97
+ class Task < ActiveRecord::Base
98
+ scope :title_matches, ->(text) {
99
+ where('title ILIKE ?', "%#{text}%")
100
+ }
101
+ end
102
+
103
+ Task.create(title: 'Low priority task')
104
+ Task.create(title: 'Fix me ASAP!!1!')
105
+
106
+ TaskQuery.new.scope.count # => 2
107
+ TaskQuery.new(query: { title_matches: 'ASAP' }).scope.count # => 1
108
+ ```
109
+
110
+ You can provide a default value with the `default` option:
111
+
112
+ ```ruby
113
+ class TasksQuery < Admino::Query::Base
114
+ # ...
115
+ search_field :title_matches, default: 'TODO'
116
+ end
117
+ ```
118
+
119
+ #### `filter_by`
120
+
121
+ ```ruby
122
+ class Task < ActiveRecord::Base
123
+ enum :status, [:pending, :completed, :archived]
124
+ scope :title_matches, ->(text) {
125
+ where('title ILIKE ?', "%#{text}%")
126
+ }
127
+ end
128
+
129
+ class TasksQuery < Admino::Query::Base
130
+ # ...
131
+ filter_by :status, [:completed, :pending]
132
+ filter_by :deleted, [:with_deleted]
133
+ filter_by :status, Task.statuses.keys
134
+ end
135
+ ```
136
+
137
+ Just like a search field, with a declared filter group the `#scope` method will check the presence of a `params[:query][:status]` key. If it finds it (and its value corresponds to one of the declared scopes) it will augment the query with the scope itself:
138
+
139
+ ```ruby
140
+ class Task < ActiveRecord::Base
141
+ scope :completed, -> { where(completed: true) }
142
+ scope :pending, -> { where(completed: false) }
143
+ end
144
+
145
+ Task.create(title: 'First task', completed: true)
146
+ Task.create(title: 'Second task', completed: true)
147
+ Task.create(title: 'Third task', completed: false)
148
+
149
+ TaskQuery.new.scope.count # => 3
150
+ TaskQuery.new(query: { status: 'completed' }).scope.count # => 2
151
+ TaskQuery.new(query: { status: 'pending' }).scope.count # => 1
152
+ TaskQuery.new(query: { status: 'foobar' }).scope.count # => 3
153
+ ```
154
+
155
+ You can include a "reset" scope with the `include_empty_scope` option, and provide a default scope with the `default` option:
156
+
157
+ ```ruby
158
+ class TasksQuery < Admino::Query::Base
159
+ # ...
160
+ filter_by :time, [:last_month, :last_week],
161
+ include_empty_scope: true,
162
+ default: :last_week
163
+ end
164
+ ```
165
+
166
+ #### `sorting`
167
+
168
+ ```ruby
169
+ class TasksQuery < Admino::Query::Base
170
+ # ...
171
+ sorting :by_due_date, :by_title
172
+ end
173
+ ```
174
+
175
+ Once you declare some sorting scopes, the query object looks for a `params[:sorting]` key. If it exists (and corresponds to one of the declared scopes), it will augment the query with the scope itself. The model named scope will be called passing an argument that represents the direction of sorting (`:asc` or `:desc`).
176
+
177
+ The direction passed to the scope will depend on the value of `params[:sort_order]`, and will default to `:asc`:
178
+
179
+ ```ruby
180
+ class Task < ActiveRecord::Base
181
+ scope :by_due_date, ->(direction) { order(due_date: direction) }
182
+ scope :by_title, ->(direction) { order(title: direction) }
183
+ end
184
+
185
+ expired_task = Task.create(due_date: 1.year.ago)
186
+ future_task = Task.create(due_date: 1.week.since)
187
+
188
+ TaskQuery.new(sorting: 'by_due_date', sort_order: 'desc').scope # => [ future_task, expired_task ]
189
+ TaskQuery.new(sorting: 'by_due_date', sort_order: 'asc').scope # => [ expired_task, future_task ]
190
+ TaskQuery.new(sorting: 'by_due_date').scope # => [ expired_task, future_task ]
191
+ ```
192
+
193
+ #### `ending_scope`
194
+
195
+ It's very common ie. to paginate a result set. The block declared in the `ending_scope` block will be always appended to the end of the chain:
196
+
197
+ ```ruby
198
+ class TasksQuery < Admino::Query::Base
199
+ ending_scope { |q| page(q.params[:page]) }
200
+ end
201
+ ```
202
+
203
+ ### Let the query object do the chaining
204
+
205
+ If you do not want to pollute your ActiveRecord model with all these scopes, you are free to implement them on the query object itself (just make sure to suffix them with `_scope`):
206
+
207
+ ```ruby
208
+ class Task < ActiveRecord::Base
209
+ end
210
+
211
+ class TasksQuery < Admino::Query::Base
212
+ search_field :title_matches
213
+
214
+ def title_matches_scope(scope, text)
215
+ scope.where('title ILIKE ?', "%#{text}%")
216
+ end
217
+ end
218
+ ```
219
+
220
+ ### Inspecting the query state
221
+
222
+ A query object supports various methods to inspect the available search fields, filters and sortings, and their state:
223
+
224
+ ```ruby
225
+ query = TaskQuery.new
226
+ query.search_fields # => [ #<Admino::Query::SearchField>, ... ]
227
+ query.filter_groups # => [ #<Admino::Query::FilterGroup>, ... ]
228
+
229
+ search_field = query.search_field_by_name(:title_matches)
230
+
231
+ search_field.name # => :title_matches
232
+ search_field.present? # => true
233
+ search_field.value # => 'ASAP'
234
+
235
+ filter_group = query.filter_group_by_name(:status)
236
+
237
+ filter_group.name # => :status
238
+ filter_group.scopes # => [ :completed, :pending ]
239
+ filter_group.active_scope # => :completed
240
+ filter_group.is_scope_active?(:pending) # => false
241
+
242
+ sorting = query.sorting # => #<Admino::Query::Sorting>
243
+ sorting.scopes # => [ :by_title, :by_due_date ]
244
+ sorting.active_scope # => :by_due_date
245
+ sorting.is_scope_active?(:by_title) # => false
246
+ sorting.ascending? # => true
247
+ ```
248
+
249
+ ### Presenting search form and filters to the user
250
+
251
+ Admino offers some helpers that make it really easy to generate search forms and filtering links:
252
+
253
+ ```erb
254
+ <%# generate the search form %>
255
+ <%= search_form_for(query) do |q| %>
256
+ <%# generate inputs from search_fields %>
257
+ <p>
258
+ <%= q.label :title_matches %>
259
+ <%= q.text_field :title_matches %>
260
+ </p>
261
+ <p>
262
+ <%= q.submit %>
263
+ </p>
264
+
265
+ <%# generate inputs from filter_by %>
266
+ <p>
267
+ <%= q.label :status %>
268
+ <%= q.select :status, Task.statuses.keys %>
269
+ </p>
270
+
271
+ <%# if filter_by has only one scope you can use a checkbox %>
272
+ <p>
273
+ <%= q.check_box :deleted, {}, checked_value: "with_deleted" %>
274
+ <%= q.label :deleted %>
275
+ </p>
276
+ <% end %>
277
+
278
+ <%# generate the filtering links %>
279
+ <% filters_for(query) do |filter_group| %>
280
+ <h6><%= filter_group.name %></h6>
281
+ <ul>
282
+ <% filter_group.each_scope do |scope| %>
283
+ <li><%= scope.link %><li>
284
+ <% end %>
285
+ </ul>
286
+ <% end %>
287
+
288
+ <%# generate the sorting links %>
289
+ <h6>Sort by</h6>
290
+ <ul>
291
+ <% sortings_for(query) do |scope| %>
292
+ <li><%= scope.link %></li>
293
+ <% end %>
294
+ </ul>
295
+ ```
296
+ The great thing is that:
297
+
298
+ * the search form gets automatically filled in with the last input the user submitted
299
+ * a `is-active` CSS class gets added to the currently active filter scopes
300
+ * if a particular filter link has been clicked and is now active, it is possible to deactivate it by clicking on the link again
301
+ * a `is-asc`/`is-desc` CSS class gets added to the currently active sorting scope
302
+ * if a particular sorting scope link has been clicked and is now in ascending order, it is possible to make it descending by clicking on the link again
303
+
304
+ ### Simple Form support
305
+
306
+ If you prefer using [Simple Form](https://github.com/plataformatec/simple_form), please use the `simple_search_form_for` helper instead.
307
+
308
+ ### Output customization
309
+
310
+ The `#link` methods are very flexible, allowing you to change almost every aspect of the generated links:
311
+
312
+ ```erb
313
+ <% filter_group.each_scope do |scope| %>
314
+ <li><%= scope.link 'Custom title',
315
+ active_class: 'active',
316
+ class: 'custom-class'
317
+ %><li>
318
+ <% end %>
319
+ ```
320
+
321
+ Please refer to the tests for the details.
322
+
323
+ ### Overwriting the starting scope
324
+
325
+ Suppose you have to filter the tasks based on the `@current_user` work group. You can easily provide an alternative starting scope from the controller passing it as an argument to the `#scope` method:
326
+
327
+ ```ruby
328
+ def index
329
+ @query = TasksQuery.new(params)
330
+ @project_tasks = @query.scope(@current_user.team.tasks)
331
+ end
332
+ ```
333
+
334
+ ### Coertions
335
+
336
+ Suppose the presence of a model scope that requires a non-textual argument (ie. a date):
337
+
338
+ ```ruby
339
+ class Task < ActiveRecord::Base
340
+ scope :due_date_from, ->(date) { where('due_date >= ?', date) }
341
+ end
342
+ ```
343
+
344
+ Admino can perform some automatic coertions to the textual parameter it gets, and pass the coerced value to the scope:
345
+
346
+ ```ruby
347
+ class TasksQuery < Admino::Query::Base
348
+ search_field :due_date_from, coerce: :to_date
349
+ end
350
+
351
+ query = TaskQuery.new(query: { due_date_from: '2014-03-01' })
352
+ query.search_field_by_name(:due_date_from).value # => #<Date Sat, 01 Mar 2014>
353
+ ```
354
+
355
+ If a specific coercion cannot be performed with the provided input, the scope won't be chained. The following coertions are available:
356
+
357
+ * `:to_boolean`
358
+ * `:to_constant`
359
+ * `:to_date`
360
+ * `:to_datetime`
361
+ * `:to_decimal`
362
+ * `:to_float`
363
+ * `:to_integer`
364
+ * `:to_symbol`
365
+ * `:to_time`
366
+
367
+ Please see the [`Coercible::Coercer::String`](https://github.com/solnic/coercible/blob/master/lib/coercible/coercer/string.rb) class for details.
368
+
369
+ ### Default sorting
370
+
371
+ If you need to setup a default sorting, you can pass some optional arguments to the `sorting` declaration:
372
+
373
+ ```ruby
374
+ class TasksQuery < Admino::Query::Base
375
+ # ...
376
+ sorting :by_due_date, :by_title,
377
+ default_scope: :by_due_date,
378
+ default_direction: :desc
379
+ end
380
+ ```
381
+
382
+ ### I18n
383
+
384
+ To localize the search form labels, as well as the group filter names and scope links, please refer to the following YAML file:
385
+
386
+ ```yaml
387
+ en:
388
+ query:
389
+ attributes:
390
+ tasks_query:
391
+ title_matches: 'Title contains'
392
+ filter_groups:
393
+ tasks_query:
394
+ status:
395
+ name: 'Filter by status'
396
+ scopes:
397
+ completed: 'Completed'
398
+ pending: 'Pending'
399
+ sorting_scopes:
400
+ task_query:
401
+ by_due_date: 'By due date'
402
+ by_title: 'By title'
403
+ ```
404
+
405
+ ## Admino::Table::Presenter
406
+
407
+ Admino offers a `table_for` helper that makes it really easy to generate HTML tables from a set of records:
408
+
409
+ ```erb
410
+ <%= table_for(@tasks, class: Task) do |row, record| %>
411
+ <%= row.column :title %>
412
+ <%= row.column :completed do %>
413
+ <%= record.completed ? '✓' : '✗' %>
414
+ <% end %>
415
+ <%= row.column :due_date %>
416
+ <% end %>
417
+ ```
418
+
419
+ With produces the following output:
420
+
421
+ ```html
422
+ <table>
423
+ <thead>
424
+ <tr>
425
+ <th role='title'>Title</th>
426
+ <th role='completed'>Completed</th>
427
+ <th role='due-date'>Due date</th>
428
+ </tr>
429
+ <thead>
430
+ <tbody>
431
+ <tr class='is-even'>
432
+ <td role='title'>Call mum ASAP</td>
433
+ <td role='completed'>✓</td>
434
+ <td role='due-date'>2013-02-04</td>
435
+ </tr>
436
+ <tr class='is-odd'>
437
+ <!-- ... -->
438
+ </tr>
439
+ <tbody>
440
+ </table>
441
+ ```
442
+
443
+ ### Record actions
444
+
445
+ Often tables need to offer some kind of action associated with the records. The table builder implements the following DSL to support that:
446
+
447
+ ```erb
448
+ <%= table_for(@tasks, class: Task) do |row, record| %>
449
+ <%# ... %>
450
+ <%= row.actions do %>
451
+ <%= row.action :show, admin_task_path(record) %>
452
+ <%= row.action :edit, edit_admin_task_path(record) %>
453
+ <%= row.action :destroy, admin_task_path(record), method: :delete %>
454
+ <% end %>
455
+ <% end %>
456
+ ```
457
+
458
+ ```html
459
+ <table>
460
+ <thead>
461
+ <tr>
462
+ <!-- ... -->
463
+ <th role='actions'>Actions</th>
464
+ </tr>
465
+ <thead>
466
+ <tbody>
467
+ <tr class='is-even'>
468
+ <!-- ... -->
469
+ <td role='actions'>
470
+ <a href='/admin/tasks/1' role='show'>Show</a>
471
+ <a href='/admin/tasks/1/edit' role='edit'>Edit</a>
472
+ <a href='/admin/tasks/1' role='destroy' data-method='delete'>Destroy</a>
473
+ </td>
474
+ </tr>
475
+ <tbody>
476
+ </table>
477
+ ```
478
+
479
+ ### Sortable columns
480
+
481
+ If you want to make the table headers sortable, then please create an Admino query object class to define the available sorting scopes.
482
+
483
+ ```ruby
484
+ class TaskQuery < Admino::Query::Base
485
+ sorting :by_title, :by_due_date
486
+ end
487
+ ```
488
+
489
+ You can then pass the query object as a parameter to the table presenter initializer, and associate table columns to specific sorting scopes of the query object using the `sorting` directive:
490
+
491
+ ```erb
492
+ <% query = present(@query) %>
493
+
494
+ <%= table_for(@tasks, class: Task) do |row, record| %>
495
+ <%= row.column :title, sorting: :by_title %>
496
+ <%= row.column :due_date, sorting: :by_due_date %>
497
+ <% end %>
498
+ ```
499
+
500
+ This generates links that allow the visitor to sort the result set in ascending and descending direction:
501
+
502
+ ```html
503
+ <table>
504
+ <thead>
505
+ <tr>
506
+ <th role='title'>
507
+ <a href='/admin/tasks?sorting=by_title&sort_order=desc' class='is-asc'>Title</a>
508
+ </th>
509
+ <th role='due-date'>
510
+ <a href='/admin/tasks?sorting=by_due_date&sort_order=asc'>Due date</a>
511
+ </th>
512
+ </tr>
513
+ <thead>
514
+ <!-- ... -->
515
+ </table>
516
+ ```
517
+
518
+ ### Customizing the output
519
+
520
+ The `#column` and `#action` methods are very flexible, allowing you to change almost every aspect of the generated table cells:
521
+
522
+ ```erb
523
+ <%= table_for(@tasks, class: Task, html: { class: 'table-class' }) do |row, record| %>
524
+ <%= row.column :title, 'Custom title',
525
+ class: 'custom-class', role: 'custom-role', data: { custom: 'true' },
526
+ sorting: :by_title, sorting_html_options: { desc_class: 'down' }
527
+ %>
528
+ <%= row.action :show, admin_task_path(record), 'Custom label',
529
+ class: 'custom-class', role: 'custom-role', data: { custom: 'true' }
530
+ %>
531
+ <% end %>
532
+ ```
533
+
534
+ If you need more power, you can also subclass `Admino::Table::Presenter`. For each HTML element, there's a set of methods you can override to customize it's appeareance.
535
+ Table cells are generated through two collaborator classes: `Admino::Table::HeadRow` and `Admino::Table::ResourceRow`. You can easily replace them with a subclass if you want. To grasp the idea here's an example:
536
+
537
+ ```ruby
538
+ class CustomTablePresenter < Admino::Table::Presenter
539
+ private
540
+
541
+ def table_html_options
542
+ { class: 'table-class' }
543
+ end
544
+
545
+ def tbody_tr_html_options(resource, index)
546
+ { class: 'tr-class' }
547
+ end
548
+
549
+ def zebra_css_classes
550
+ %w(one two three)
551
+ end
552
+
553
+ def resource_row(resource, view_context)
554
+ ResourceRow.new(resource, view_context)
555
+ end
556
+
557
+ def head_row(collection_klass, query, view_context)
558
+ HeadRow.new(collection_klass, query, view_context)
559
+ end
560
+
561
+ class ResourceRow < Admino::Table::ResourceRow
562
+ private
563
+
564
+ def action_html_options(action_name)
565
+ { class: 'action-class' }
566
+ end
567
+
568
+ def show_action_html_options
569
+ { class: 'show-action-class' }
570
+ end
571
+
572
+ def column_html_options(attribute_name)
573
+ { class: 'column-class' }
574
+ end
575
+ end
576
+
577
+ class HeadRow < Admino::Table::ResourceRow
578
+ def column_html_options(attribute_name)
579
+ { class: 'column-class' }
580
+ end
581
+ end
582
+ end
583
+ ```
584
+
585
+ ```erb
586
+ <%= table_for(@tasks, class: Task, presenter: CustomTablePresenter) do |row, record| %>
587
+ <%= row.column :title, 'Custom title',
588
+ class: 'custom-class', role: 'custom-role', data: { custom: 'true' },
589
+ sorting: :by_title, sorting_html_options: { desc_class: 'down' }
590
+ %>
591
+ <%= row.action :show, admin_task_path(record), 'Custom label',
592
+ class: 'custom-class', role: 'custom-role', data: { custom: 'true' }
593
+ %>
594
+ <% end %>
595
+ ```
596
+
597
+ Please refer to the tests for all the details.
598
+
599
+ ### Inherited resources (and similar)
600
+
601
+ If your controller actions are generated through [Inherited Resources](https://github.com/josevalim/inherited_resources), then you can always get the URL pointing to the show action with the `resource_path` helper method. Similar helpers [are available for the other REST actions too](https://github.com/josevalim/inherited_resources#url-helpers) (new, edit, destroy).
602
+
603
+ More in general, if you are able to programmatically generate/obtain the URLs of your row actions, you can subclass `Admino::Table::Presenter` and declare them:
604
+
605
+ ```ruby
606
+ class CustomTablePresenter < Admino::Table::Presenter
607
+ private
608
+
609
+ def resource_row(resource, view_context)
610
+ ResourceRow.new(resource, view_context)
611
+ end
612
+
613
+ class ResourceRow < Admino::Table::ResourceRow
614
+ def show_action_url
615
+ h.resource_url(resource)
616
+ end
617
+
618
+ def edit_action_url
619
+ h.edit_resource_url(resource)
620
+ end
621
+
622
+ def destroy_action_url
623
+ h.resource_url(resource)
624
+ end
625
+
626
+ def destroy_action_html_options
627
+ { method: :delete }
628
+ end
629
+ end
630
+ end
631
+ ```
632
+
633
+ This will enable you to generate row actions even faster, simply declaring them as arguments to the `#actions` DSL method:
634
+
635
+ ```erb
636
+ <%= table_for(@tasks, class: Task, presenter: CustomTablePresenter) do |row, record| %>
637
+ <%# ... %>
638
+ <%= row.actions :show, :edit, :destroy %>
639
+ <% end %>
640
+ ```
641
+
642
+ ### Showcase::Traits::Record
643
+
644
+ As funny it may sound, it is strongly suggested to pass to the table presenter an array of records which in turn have been already presented. This enables you to use as columns not only the raw attributes of the model, but all the methods defined in the presenter.
645
+
646
+ Furthermore, if the record presenter includes the `Showcase::Traits::Record` trait, each row of the table will automatically have an unique id attribute thanks to the [`#dom_id` method](https://github.com/stefanoverna/showcase#dom_id).
647
+
648
+ ```ruby
649
+ class TaskPresenter < Showcase::Presenter
650
+ include Showcase::Traits::Record
651
+
652
+ def truncated_title
653
+ h.truncate(title, length: 50)
654
+ end
655
+ end
656
+ ```
657
+
658
+ ```erb
659
+ <% tasks = present_collection(@tasks)
660
+
661
+ <%= Admino::Table::Presenter.new(tasks, Task, self).to_html do |row, record| %>
662
+ <%= row.column :truncated_title, 'Title' %>
663
+ <% end %>
664
+ ```
665
+
666
+ ```html
667
+ <table>
668
+ <thead>
669
+ <th role='truncated-title'>Title</th>
670
+ <thead>
671
+ <tbody>
672
+ <tr id='task_1' class='is-even'>
673
+ <td role='truncated-title'>Call mum ASAP</td>
674
+ </tr>
675
+ <tr id='task_2' class='is-odd'>
676
+ <td role='truncated-title'>Buy some milk</td>
677
+ </tr>
678
+ <tbody>
679
+ </table>
680
+ ```
681
+
682
+ ### I18n
683
+
684
+ Column titles are generated using the model [`#human_attribute_name`](http://apidock.com/rails/ActiveRecord/Base/human_attribute_name/class) method, so if you already translated the model attribute names, you're good to go. To translate actions, please refer to the following YAML file:
685
+
686
+ ```yaml
687
+ en:
688
+ activerecord:
689
+ attributes:
690
+ task:
691
+ title: 'Title'
692
+ due_date: 'Due date'
693
+ completed: 'Completed?'
694
+ table:
695
+ actions:
696
+ task:
697
+ title: 'Actions'
698
+ show: 'Details'
699
+ edit: 'Edit task'
700
+ destroy: 'Delete'
701
+ ```
702
+
703
+ ## Running tests
704
+
705
+ Install gems:
706
+
707
+ ```
708
+ $ bundle
709
+ $ bundle exec appraisal
710
+ ```
711
+
712
+ Launch tests:
713
+
714
+ ```
715
+ bundle exec appraisal rake
716
+ ```