Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for virtual fields #2658

Merged
merged 14 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/customizing_dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] %>
pablobm marked this conversation as resolved.
Show resolved Hide resolved
<% 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
Loading