diff --git a/docs/customizing_dashboards.md b/docs/customizing_dashboards.md index 6d4d839d49..e5ea877ffc 100644 --- a/docs/customizing_dashboards.md +++ b/docs/customizing_dashboards.md @@ -436,3 +436,133 @@ en: ``` If not defined (see `SHOW_PAGE_ATTRIBUTES` above), Administrate will default to the given strings. + +## Virtual Attributes + +For all field types, you can use the `getter` option to change where the data is retrieved from or to set the data directly. + +By using this, you can define an attribute in `ATTRIBUTE_TYPES` that doesn’t exist in the model, and use it for various purposes. + +### Attribute Aliases + +You can create an alias for an attribute. For example: + +```ruby + ATTRIBUTE_TYPES = { + shipped_at: Field::DateTime, + shipped_on: Field::Date.with_options( + getter: :shipped_at + ) + } + COLLECTION_ATTRIBUTES = [ + :shipped_on + } + SHOW_PAGE_ATTRIBUTES = [ + :shipped_at + } +``` + +In this example, a virtual attribute `shipped_on` based on the value of `shipped_at` is defined as a `Date` type and used for display on the index page (this can help save table cell space). + +### Decorated Attributes + +You can also use this to decorate data. For example: + +```ruby + ATTRIBUTE_TYPES = { + price: Field::Number, + price_including_tax: Field::Number.with_options( + getter: -> (field) { + field.resource.price * 1.1 if field.resource.price.present? + } + ) + } +``` + +### Composite Attributes + +You can dynamically create a virtual attribute by combining multiple attributes for display. For example: + +```ruby + ATTRIBUTE_TYPES = { + first_name: Field::String, + last_name: Field::String, + full_name: Field::String.with_options( + getter: -> (field) { + [ + field.resource.first_name, + field.resource.last_name + ].compact_blank.join(' ') + } + ) + } +``` + +## Virtual Fields + +Custom fields can also be defined using virtual fields. + +```ruby + ATTRIBUTE_TYPES = { + id: Field::Number, + receipt: Field::ReceiptLink + } +``` + +```ruby +module Administrate + module Field + class ReceiptLink < Base + def data + resource.id + end + + def filename + "receipt-#{data}.pdf" + end + + def url + "/files/receipts/#{filename}" + end + end + end +end +``` + +```erb +<%= link_to field.filename, field.url %> +``` + +### Custom Actions via Virtual Field + +By creating custom fields that are not dependent on specific attributes, you can insert custom views into any screen. +For example, you can add custom buttons like this: + +```ruby + ATTRIBUTE_TYPES = { + id: Field::Number, + custom_index_actions: Field::CustomActionButtons, + custom_show_actions: Field::CustomActionButtons, + } +``` + +```ruby +module Administrate + module Field + class CustomActionButtons < Base + def data + resource.id + end + end + end +end +``` + +```erb +<%# app/views/fields/custom_action_buttons/_index.html.erb %> +<% if field.data.present? %> + <%= button_to "some action 1", [:some_action_1, namespace, field.resource] %> + <%= button_to "some action 2", [:some_action_2, namespace, field.resource] %> + <%= button_to "some action 3", [:some_action_3, namespace, field.resource] %> +<% end %> +``` diff --git a/lib/administrate/field/base.rb b/lib/administrate/field/base.rb index c086603afb..65121587c9 100644 --- a/lib/administrate/field/base.rb +++ b/lib/administrate/field/base.rb @@ -34,10 +34,10 @@ def self.permitted_attribute(attr, _options = nil) def initialize(attribute, data, page, options = {}) @attribute = attribute - @data = data @page = page @resource = options.delete(:resource) @options = options + @data = read_value(data) end def html_class @@ -52,6 +52,20 @@ def name attribute.to_s end + def read_value(data) + if options.key?(:getter) + if options[:getter].respond_to?(:call) + options[:getter].call(self) + else + resource.try(options[:getter]) + end + elsif data.nil? + resource.try(attribute) + else + data + end + end + def to_partial_path "/fields/#{self.class.field_type}/#{page}" end diff --git a/lib/administrate/field/deferred.rb b/lib/administrate/field/deferred.rb index 10609735af..ae3a79e851 100644 --- a/lib/administrate/field/deferred.rb +++ b/lib/administrate/field/deferred.rb @@ -21,6 +21,10 @@ def ==(other) options == other.options end + def getter + options.fetch(:getter, nil) + end + def associative? deferred_class.associative? end @@ -30,7 +34,11 @@ def eager_load? end def searchable? - options.fetch(:searchable, deferred_class.searchable?) + if options.key?(:getter) + false + else + options.fetch(:searchable, deferred_class.searchable?) + end end def searchable_field diff --git a/lib/administrate/page/base.rb b/lib/administrate/page/base.rb index 78fbf1a6bd..56c61892d3 100644 --- a/lib/administrate/page/base.rb +++ b/lib/administrate/page/base.rb @@ -30,13 +30,8 @@ def item_associations private def attribute_field(dashboard, resource, attribute_name, page) - value = get_attribute_value(resource, attribute_name) field = dashboard.attribute_type_for(attribute_name) - field.new(attribute_name, value, page, resource: resource) - end - - def get_attribute_value(resource, attribute_name) - resource.public_send(attribute_name) + field.new(attribute_name, nil, page, resource: resource) end attr_reader :dashboard, :options diff --git a/spec/example_app/app/controllers/files_controller.rb b/spec/example_app/app/controllers/files_controller.rb new file mode 100644 index 0000000000..e6f6b2ea15 --- /dev/null +++ b/spec/example_app/app/controllers/files_controller.rb @@ -0,0 +1,12 @@ +class FilesController < ApplicationController + def download + filename = params[:filename] + match = %r{receipt-(\d+)}.match(filename) + if match + payment_id = match[1] + send_data("This is the receipt for payment ##{payment_id}", filename: "#{filename}.txt") + else + render status: 404, layout: false, file: Rails.root.join("public/404.html") + end + end +end diff --git a/spec/example_app/app/dashboards/order_dashboard.rb b/spec/example_app/app/dashboards/order_dashboard.rb index 1e1407b52d..631572e217 100644 --- a/spec/example_app/app/dashboards/order_dashboard.rb +++ b/spec/example_app/app/dashboards/order_dashboard.rb @@ -10,6 +10,18 @@ class OrderDashboard < Administrate::BaseDashboard address_city: Field::String, address_state: Field::String, address_zip: Field::String, + full_address: Field::String.with_options( + getter: ->(field) { + r = field.resource + [ + r.address_line_one, + r.address_line_two, + r.address_city, + r.address_state, + r.address_zip + ].compact.join("\n") + } + ), customer: Field::BelongsTo.with_options(order: "name"), line_items: Field::HasMany.with_options( collection_attributes: %i[product quantity unit_price total_price] @@ -29,7 +41,7 @@ class OrderDashboard < Administrate::BaseDashboard COLLECTION_ATTRIBUTES = [ :id, :customer, - :address_state, + :full_address, :total_price, :line_items, :shipped_at @@ -50,8 +62,11 @@ class OrderDashboard < Administrate::BaseDashboard address_zip ] }.freeze - SHOW_PAGE_ATTRIBUTES = FORM_ATTRIBUTES.merge( - "" => %i[customer created_at updated_at], - "details" => %i[line_items total_price shipped_at payments] - ).freeze + SHOW_PAGE_ATTRIBUTES = FORM_ATTRIBUTES + .except("address") + .merge( + "" => %i[customer full_address created_at updated_at], + "details" => %i[line_items total_price shipped_at payments] + ) + .freeze end diff --git a/spec/example_app/app/dashboards/payment_dashboard.rb b/spec/example_app/app/dashboards/payment_dashboard.rb index 2460311de2..a7ede659be 100644 --- a/spec/example_app/app/dashboards/payment_dashboard.rb +++ b/spec/example_app/app/dashboards/payment_dashboard.rb @@ -1,15 +1,18 @@ +require "administrate/field/receipt_link" require "administrate/base_dashboard" class PaymentDashboard < Administrate::BaseDashboard ATTRIBUTE_TYPES = { id: Field::Number, + receipt: Field::ReceiptLink, created_at: Field::DateTime, updated_at: Field::DateTime, order: Field::BelongsTo } COLLECTION_ATTRIBUTES = [ - :id + :id, + :receipt ] SHOW_PAGE_ATTRIBUTES = ATTRIBUTE_TYPES.keys diff --git a/spec/example_app/app/views/fields/receipt_link/_index.html.erb b/spec/example_app/app/views/fields/receipt_link/_index.html.erb new file mode 100644 index 0000000000..9f755667e8 --- /dev/null +++ b/spec/example_app/app/views/fields/receipt_link/_index.html.erb @@ -0,0 +1 @@ +<%= link_to field.filename, field.data %> diff --git a/spec/example_app/app/views/fields/receipt_link/_show.html.erb b/spec/example_app/app/views/fields/receipt_link/_show.html.erb new file mode 100644 index 0000000000..9f755667e8 --- /dev/null +++ b/spec/example_app/app/views/fields/receipt_link/_show.html.erb @@ -0,0 +1 @@ +<%= link_to field.filename, field.data %> diff --git a/spec/example_app/config/routes.rb b/spec/example_app/config/routes.rb index f8fc984e8b..53ce954aec 100644 --- a/spec/example_app/config/routes.rb +++ b/spec/example_app/config/routes.rb @@ -24,6 +24,8 @@ root to: "customers#index" end + get "/files/receipts/*filename.txt", to: "files#download" + get "/*page", to: "docs#show", constraints: ->(request) { !request.path.start_with?("/rails/") } root to: "docs#index" end diff --git a/spec/example_app/lib/administrate/field/receipt_link.rb b/spec/example_app/lib/administrate/field/receipt_link.rb new file mode 100644 index 0000000000..bbe7192109 --- /dev/null +++ b/spec/example_app/lib/administrate/field/receipt_link.rb @@ -0,0 +1,15 @@ +require "administrate/field/base" + +module Administrate + module Field + class ReceiptLink < Base + def data + "/files/receipts/#{filename}" + end + + def filename + "receipt-#{resource.id}.txt" + end + end + end +end diff --git a/spec/features/payments_index_spec.rb b/spec/features/payments_index_spec.rb new file mode 100644 index 0000000000..3b21ce23b4 --- /dev/null +++ b/spec/features/payments_index_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe "payment index page" do + it "displays payments' id and receipt link" do + payment = create(:payment) + + visit admin_payments_path + + expect(page).to have_header("Payments") + expect(page).to have_content(payment.id) + expect(page).to have_content("receipt-#{payment.id}.txt") + end + + it "allows downloading the receipt" do + payment = create(:payment) + + visit admin_payments_path + click_on("receipt-#{payment.id}.txt") + + expect(page.body).to eq("This is the receipt for payment ##{payment.id}") + expect(response_headers["Content-Disposition"]).to match(%r{^attachment; filename=}) + end + + it "links to the payment show page", :js do + payment = create(:payment) + + visit admin_payments_path + click_row_for(payment) + + expect(page).to have_content(payment.id) + expect(page).to have_current_path(admin_payment_path(payment)) + end +end diff --git a/spec/lib/fields/base_spec.rb b/spec/lib/fields/base_spec.rb index 43b71efc50..44af91340e 100644 --- a/spec/lib/fields/base_spec.rb +++ b/spec/lib/fields/base_spec.rb @@ -167,4 +167,48 @@ expect(field.required?).to eq(false) end end + + describe "#data" do + context "when given nil data" do + it "reads the value from the resource" do + resource = double(attribute: "resource value") + field = field_class.new(:attribute, nil, :page, resource: resource) + + expect(field.data).to eq("resource value") + end + end + + context "when given non-nil data" do + it "uses the given data" do + resource = double(attribute: "resource value") + field = field_class.new(:attribute, "given value", :page, resource: resource) + + expect(field.data).to eq("given value") + end + end + + context "when given a :getter value" do + it "reads the attribute with the name of the value" do + resource = double(custom_getter: "custom value") + field = field_class.new(:attribute, :date, :page, resource: resource, getter: :custom_getter) + + expect(field.data).to eq("custom value") + end + end + + context "when given a :getter block" do + it "uses it to produce a value" do + resource = double("Model", custom_getter: "custom value") + field = field_class.new(:attribute, :date, :page, resource: resource, getter: ->(f) { f.resource.custom_getter + " from block" }) + + expect(field.data).to eq("custom value from block") + end + + it "returns nil if the resource is nil" do + field = field_class.new(:attribute, nil, :page, resource: nil) + + expect(field.data).to eq(nil) + end + end + end end