README.md in admino-0.0.4 vs README.md in admino-0.0.5

- old
+ new

@@ -17,11 +17,11 @@ 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. ### A modular approach to the problem -The great thing is that you don't need to write a lot of code to get a more maintainable and modular administrative area. +The great thing is that you don't need to write a lot of code to get a more maintainable and modular administrative area. 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. 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. 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. @@ -36,73 +36,168 @@ $ bundle ## Admino::Query::Base -A subclass of `Admino::Query::Base` represents a [Query object](http://martinfowler.com/eaaCatalog/queryObject.html), that is, an object responsible for returning a result set (ie. an `ActiveRecord::Relation`) based on business rules (ie. action params). +`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. -Given a `Task` model with the following scopes: +Given a `Task` model, we can generate a `TasksQuery` query object subclassing `Admino::Query::Base`: ```ruby -class Task < ActiveRecord::Base - scope :text_matches, ->(text) { where(...) } +class TasksQuery < Admino::Query::Base +end +``` - scope :completed, -> { where(completed: true) } - scope :pending, -> { where(completed: false) } +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 controller actions: - scope :by_due_date, ->(direction) { order(due_date: direction) } - scope :by_title, ->(direction) { order(title: direction) } +```ruby +class TasksController < ApplicationController + def index + @query = TasksQuery.new(params) + @tasks = @query.scope + end end ``` -The following `TasksQuery` class can be created: +### Building the query itself +You can specify how a `TaskQuery` must build a result set through a simple DSL. + +#### `starting_scope` + +The `starting_scope` method is in charge of defining the scope that will start the filtering/ordering chain: + ```ruby class TasksQuery < Admino::Query::Base - starting_scope { ProjectTask.all } + starting_scope { Task.all } +end - field :text_matches +Task.create(title: 'Low priority task') + +TaskQuery.new.scope.count # => 1 +``` + +#### `search_field` + +Once you define the following field: + +```ruby +class TasksQuery < Admino::Query::Base + # ... + search_field :title_matches +end +``` +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, that needs to accept an argument. + +```ruby +class Task < ActiveRecord::Base + scope :title_matches, ->(text) { + where('title ILIKE ?', "%#{text}%") + } +end + +Task.create(title: 'Low priority task') +Task.create(title: 'Fix me ASAP!!1!') + +TaskQuery.new.scope.count # => 2 +TaskQuery.new(query: { title_matches: 'ASAP' }).scope.count # => 1 +``` + +#### `filter_by` + +```ruby +class TasksQuery < Admino::Query::Base + # ... filter_by :status, [:completed, :pending] - sorting :by_due_date, :by_title end ``` -Every query object can declare: +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 the scope itself: -* a **starting scope**, that is, the scope that will start the filtering/ordering chain; -* a set of **search fields**, which represent model scopes that require an input to filter the result set; -* a set of **filtering groups**, each of which is composed by a set of scopes that take no argument; -* a set of **sorting scopes** that take a sigle argument (`:asc` or `:desc`) and thus are able to order the result set in both directions; +```ruby +class Task < ActiveRecord::Base + scope :completed, -> { where(completed: true) } + scope :pending, -> { where(completed: false) } +end -Each query object instance gets initialized with a hash of params. The `#scope` method will then perform the chaining of the scopes based on the given params, returning the final result set: +Task.create(title: 'First task', completed: true) +Task.create(title: 'Second task', completed: true) +Task.create(title: 'Third task', completed: false) +TaskQuery.new.scope.count # => 3 +TaskQuery.new(query: { status: 'completed' }).scope.count # => 2 +TaskQuery.new(query: { status: 'pending' }).scope.count # => 1 +TaskQuery.new(query: { status: 'foobar' }).scope.count # => 3 +``` + +#### `sorting` + ```ruby -params = { - query: { - text_matches: 'ASAP' - }, - status: 'pending', - sorting: 'by_title', - sort_order: 'desc' -} +class TasksQuery < Admino::Query::Base + # ... + sorting :by_due_date, :by_title +end +``` -tasks = TasksQuery.new(params).scope +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`). + +The direction passed to the scope will depend on the value of `params[:sort_order]`, and will default to `:asc`: + +```ruby +class Task < ActiveRecord::Base + scope :by_due_date, ->(direction) { order(due_date: direction) } + scope :by_title, ->(direction) { order(title: direction) } +end + +expired_task = Task.create(due_date: 1.year.ago) +future_task = Task.create(due_date: 1.week.since) + +TaskQuery.new(sorting: 'by_due_date', sort_order: 'desc').scope # => [ future_task, expired_task ] +TaskQuery.new(sorting: 'by_due_date', sort_order: 'asc').scope # => [ expired_task, future_task ] +TaskQuery.new(sorting: 'by_due_date').scope # => [ expired_task, future_task ] ``` -As you may have guessed, query objects can be great companions to index controller actions: +#### `ending_scope` +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: + ```ruby -class ProjectTasksController < ApplicationController - def index - @query = TasksQuery.new(params) - @project_tasks = @query.scope - end +class TasksQuery < Admino::Query::Base + ending_scope { |q| page(q.params[:page]) } end ``` -But that's not all. +### Inspecting the query state +A query object supports various methods to inspect the available search fields, filters and sortings, and their state: + +```ruby +query = TaskQuery.new +query.search_fields # => [ #<Admino::Query::SearchField>, ... ] +query.filter_groups # => [ #<Admino::Query::FilterGroup>, ... ] + +search_field = query.search_field_by_name(:title_matches) + +search_field.name # => :title_matches +search_field.present? # => true +search_field.value # => 'ASAP' + +filter_group = query.filter_group_by_name(:status) + +filter_group.name # => :status +filter_group.scopes # => [ :completed, :pending ] +filter_group.active_scope # => :completed +filter_group.is_scope_active?(:pending) # => false + +sorting = query.sorting # => #<Admino::Query::Sorting> +sorting.scopes # => [ :by_title, :by_due_date ] +sorting.active_scope # => :by_due_date +sorting.is_scope_active?(:by_title) # => false +sorting.ascending? # => true +``` + ### Presenting search form and filters to the user Admino also offers a [Showcase presenter](https://github.com/stefanoverna/showcase) that makes it really easy to generate search forms and filtering links: ```erb @@ -110,12 +205,12 @@ <% query = present(@query) %> <%# generate the search form %> <%= query.form do |q| %> <p> - <%= q.label :text_matches %> - <%= q.text_field :text_matches %> + <%= q.label :title_matches %> + <%= q.text_field :title_matches %> </p> <p> <%= q.submit %> </p> <% end %> @@ -129,15 +224,29 @@ <%= filter_group.scope_link(scope) %> <li> <% end %> </ul> <% end %> + +<%# generate the sorting links %> +<h6>Sort by</h6> +<ul> + <% query.sorting.scopes.each do |scope| %> + <li> + <%= query.sorting.scope_link(scope) %> + </li> + <% end %> +</ul> ``` -The great thing is that the search form gets automatically filled in with the last input the user submitted, and a CSS class `is-active` gets added to the currently active filter scopes. +The great thing is that: -If a particular filter has been clicked and is now active, it is possible to deactivate it by clicking it again. +* the search form gets automatically filled in with the last input the user submitted +* a `is-active` CSS class gets added to the currently active filter scopes +* if a particular filter link has been clicked and is now active, it is possible to deactivate it by clicking on the link again +* a `is-asc`/`is-desc` CSS class gets added to the currently active sorting scope +* 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 ### Simple Form support The presenter also offers a `#simple_form` method to make it work with [Simple Form](https://github.com/plataformatec/simple_form) out of the box. @@ -148,39 +257,41 @@ ```yaml en: query: attributes: tasks_query: - text_matches: 'Contains text' + title_matches: 'Title contains' filter_groups: tasks_query: status: name: 'Filter by status' scopes: completed: 'Completed' pending: 'Pending' + sorting_scopes: + task_query: + by_due_date: 'By due date' + by_title: 'By title' ``` -### Output customisation +### Output customization -The query object and its presenter implement a number of additional methods and optional arguments that allow a great amount of flexibility: please refer to the tests to see all the possibile customisations available. +The presenter supports a number of optional arguments that allow a great amount of flexibility regarding customization of CSS classes, labels and HTML attributes. Please refer to the tests for the details. -#### Overwriting the starting scope +### Overwriting the starting scope 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: ```ruby def index @query = TasksQuery.new(params) @project_tasks = @query.scope(@current_user.team.tasks) end ``` -### Default sortings +### Coertions -#### Coertions - Admino can perform automatic coertions from a param string input to the type needed by the model named scope: ```ruby class TasksQuery < Admino::Query::Base # ... @@ -202,19 +313,251 @@ If a specific coercion cannot be performed with the provided input, the scope won't be chained. Please see the [`Coercible::Coercer::String`](https://github.com/solnic/coercible/blob/master/lib/coercible/coercer/string.rb) class for details. -### Ending the scope chain +### Default sorting -It's very common ie. to paginate the result set. `Admino::Query::Base` DSL makes it easy to append any scope to the end of the chain: +If you need to setup a default sorting, you can pass some optional arguments to a `scoping` declaration: ```ruby class TasksQuery < Admino::Query::Base - ending_scope { |q| page(q.params[:page]) } + # ... + sorting :by_due_date, :by_title, + default_scope: :by_due_date, + default_direction: :desc end ``` ## Admino::Table::Presenter -WIP +Admino offers a [Showcase collection presenter](https://github.com/stefanoverna/showcase) that makes it really easy to generate HTML tables from a set of records: + +```erb +<%= Admino::Table::Presenter.new(@tasks, Task, self).to_html do |row, record| %> + <%= row.column :title %> + <%= row.column :completed do %> + <%= record.completed ? '✓' : '✗' %> + <% end %> + <%= row.column :due_date %> +<% end %> +``` + +```html +<table> + <thead> + <tr> + <th role='title'>Title</th> + <th role='completed'>Completed</th> + <th role='due_date'>Due date</th> + </tr> + <thead> + <tbody> + <tr id='task_1' class='is-even'> + <td role='title'>Call mum ASAP</td> + <td role='completed'>✓</td> + <td role='due_date'>2013-02-04</td> + </tr> + <tr id='task_2' class='is-odd'> + <!-- ... --> + </tr> + <tbody> +</table> +``` + +### Record actions + +Often table rows needs to offer some kind of action associated with the record. The presenter implements the following DSL to support that: + +```erb +<%= Admino::Table::Presenter.new(@tasks, Task, self).to_html do |row, record| %> + <%# ... %> + <%= row.actions do %> + <%= row.action :show, admin_task_path(record) %> + <%= row.action :edit, edit_admin_task_path(record) %> + <%= row.action :destroy, admin_task_path(record), method: :delete %> + <% end %> +<% end %> +``` + +```html +<table> + <thead> + <tr> + <!-- ... --> + <th role='actions'>Actions</th> + </tr> + <thead> + <tbody> + <tr id='task_1' class='is-even'> + <!-- ... --> + <td role='actions'> + <a href='/admin/tasks/1' role='show'>Show</a> + <a href='/admin/tasks/1/edit' role='edit'>Edit</a> + <a href='/admin/tasks/1' role='destroy' data-method='delete'>Destroy</a> + </td> + </tr> + <tbody> +</table> +``` + +### Sortable columns + +Once a query object is passed to the presenter, columns can be associated to specific sorting scopes of the query object using the `sorting` option: + +```erb +<% query = present(@query) %> + +<%= Admino::Table::Presenter.new(@tasks, Task, query, self).to_html do |row, record| %> + <%= row.column :title, sorting: :by_title %> + <%= row.column :due_date, sorting: :by_due_date %> +<% end %> +``` + +This generates links that allow the visitor to sort the result set in ascending and descending direction: + +```html +<table> + <thead> + <tr> + <th role='title'> + <a href="/admin/tasks?sorting=by_title&sort_order=desc" class='is-asc'>Title</a> + </th> + <th role='due_date'> + <a href="/admin/tasks?sorting=by_due_date&sort_order=asc" class='is-asc'>Due date</a> + </th> + </tr> + <thead> + <!-- ... --> +</table> +``` + +### Customizing the output + +The `#column` and `#action` methods are very flexible, allowing youto change almost every aspect of the generated table cells: + +```erb +<%= Admino::Table::Presenter.new(@tasks, Task, self).to_html(class: 'table-class') do |row, record| %> + <%= row.column :title, 'Custom title', + class: 'custom-class', role: 'custom-role', data: { custom: 'true' }, + sorting: :by_title, sorting_html_options: { desc_class: 'down' } + %> + <%= row.action :show, admin_task_path(record), 'Custom label', + class: 'custom-class', role: 'custom-role', data: { custom: 'true' } + %> +<% end %> +``` + +If you need more power, you can also decide to subclass `Admino::Table::Presenter`. For each HTML element, there's a set of methods you can override to customize it's appeareance. +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: + +```ruby +class CustomTablePresenter < Admino::Table::Presenter + private + + def table_html_options + { class: 'table-class' } + end + + def tbody_tr_html_options(resource_index) + { class: 'tr-class' } + end + + def zebra_css_classes + %w(one two three) + end + + def resource_row(resource, view_context) + ResourceRow.new(resource, view_context) + end + + def head_row(collection_klass, query, view_context) + HeadRow.new(collection_klass, query, view_context) + end + + class ResourceRow < Admino::Table::ResourceRow + private + + def action_html_options(action_name) + { class: 'action-class' } + end + + def show_action_html_options + { class: 'show-action-class' } + end + + def column_html_options(attribute_name) + { class: 'column-class' } + end + end + + class HeadRow < Admino::Table::ResourceRow + def column_html_options(attribute_name) + { class: 'column-class' } + end + end +end +``` + +Please refer to the tests for all the details. + +### Inherited resources + +If the action URLs can be programmatically generated, it becomes even easier to specify the table actions: + +```erb +<%= CustomTablePresenter.new(@tasks, Task, self).to_html do |row, record| %> + <%# ... %> + <%= row.actions :show, :edit, :destroy %> +<% end %> +``` +For instance, using [Inherited Resources](https://github.com/josevalim/inherited_resources) to generate controller actions, you can use its [helper methods](https://github.com/josevalim/inherited_resources#url-helpers) to build a custom subclass of `Admino::Table::Presenter`: + +```ruby +class CustomTablePresenter < Admino::Table::Presenter + private + + def resource_row(resource, view_context) + ResourceRow.new(resource, view_context) + end + + class ResourceRow < Admino::Table::ResourceRow + def show_action_url + h.resource_url(resource) + end + + def edit_action_url + h.edit_resource_url(resource) + end + + def destroy_action_url + h.resource_url(resource) + end + + def destroy_action_html_options + { method: :delete } + end + end +end +``` + +### I18n + +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: + +```yaml +en: + activerecord: + attributes: + task: + title: 'Title' + due_date: 'Due date' + completed: 'Completed?' + table: + actions: + task: + title: 'Actions' + show: 'Details' + edit: 'Edit task' + destroy: 'Delete' +```