Skip to content

Commit

Permalink
Support for virtual fields (#2658)
Browse files Browse the repository at this point in the history
Co-authored-by: Pablo Brasero <pablo@pablobm.com>
  • Loading branch information
goosys and pablobm authored Oct 25, 2024
1 parent 5d209e9 commit 1eac3d7
Show file tree
Hide file tree
Showing 13 changed files with 287 additions and 14 deletions.
130 changes: 130 additions & 0 deletions docs/customizing_dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,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 %>
```
16 changes: 15 additions & 1 deletion lib/administrate/field/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lib/administrate/field/deferred.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ def ==(other)
options == other.options
end

def getter
options.fetch(:getter, nil)
end

def associative?
deferred_class.associative?
end
Expand All @@ -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
Expand Down
7 changes: 1 addition & 6 deletions lib/administrate/page/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions spec/example_app/app/controllers/files_controller.rb
Original file line number Diff line number Diff line change
@@ -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
25 changes: 20 additions & 5 deletions spec/example_app/app/dashboards/order_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -29,7 +41,7 @@ class OrderDashboard < Administrate::BaseDashboard
COLLECTION_ATTRIBUTES = [
:id,
:customer,
:address_state,
:full_address,
:total_price,
:line_items,
:shipped_at
Expand All @@ -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
5 changes: 4 additions & 1 deletion spec/example_app/app/dashboards/payment_dashboard.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= link_to field.filename, field.data %>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= link_to field.filename, field.data %>
2 changes: 2 additions & 0 deletions spec/example_app/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions spec/example_app/lib/administrate/field/receipt_link.rb
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions spec/features/payments_index_spec.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions spec/lib/fields/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 1eac3d7

Please sign in to comment.