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

Conversation

goosys
Copy link
Contributor

@goosys goosys commented Sep 10, 2024

Support for virtual fields, based on #1586.

Usage

  • It is possible to use a name as attribute_name that doesn't exist in the DB column or as a method in the model.

image

class CustomerDashboard < Administrate::BaseDashboard
  ATTRIBUTE_TYPES = {
    name: Field::String, # real field
    nickname: Field::String.with_options( # virtual field
      # You can specify a symbol if Customer#generate_nickname is defined
      getter: :generate_nickname,
      # Or, You can directly specify a function
      getter: ->(field) { "Mr. #{field.resource.name}man" if field.resource.name.present? },
      # Virtual fields cannot be searchable, so 'false' must be specified
      searchable: false 
    ),

(Note: The Sortable implementation has been split into #2659.)


What do you think? I'll make any necessary changes.
Please review it.

@goosys goosys mentioned this pull request Sep 10, 2024
@goosys goosys force-pushed the issue-1586-virtual-fields branch from 53f50e0 to 92f01f3 Compare September 13, 2024 11:55
@pablobm
Copy link
Collaborator

pablobm commented Sep 13, 2024

Thank you for this. I hope I can remember things clearly, let's see what we can do here... 👀

sortable option

First, you are also introducing the sortable option, which is a separate concern. That would be best served in a separate PR.

By all means, it's really good that you are implementing sortable (it's been requested before, see #1314), but separate, smaller PRs are easier to review and reason about. I'll be happy to review that.

The virtual field

The solution you propose is different from the one that was lost when #920 was merged, resulting in issue #1586.

You propose an option :getter to define how a field will be accessed. In contrast, the lost functionality allowed the creation of fields that didn't need special options or interfaces.

So for example, it was possible to have this:

## field/foobar.rb
require_relative "base"

module Administrate
  module Field
    class Foobar < Field::Base
      def self.searchable?
        false
      end

      def foobar
        "This is a Foobar: #{resource.inspect}"
      end
    end
  end
end

## views/fields/foobar/_collection.html.erb
<%= field.foobar %>

In my view, the broken functionality was simpler to use and I would prefer that to return.

Also, I see that your code would only work when when @data.nil?. This could lead to unexpected behaviour, as it's perfectly normal for a non-virtual field to be nil instead of having a value. In my mind, it goes "if it's nil, perhaps it's a virtual field".

In comparison, the original functionality established distinction between virtual and non-virtual fields. In my mind, it goes "it is virtual or it is not, but there's no confusion".

I hope that makes sense.

What do you think? Would you be able to separate the sortable code into a different PR, and change this one to behave the way I describe?

@goosys goosys force-pushed the issue-1586-virtual-fields branch from 92f01f3 to f3f53c2 Compare September 13, 2024 18:01
@goosys goosys force-pushed the issue-1586-virtual-fields branch from f3f53c2 to f377679 Compare September 13, 2024 18:03
@goosys
Copy link
Contributor Author

goosys commented Sep 13, 2024

@pablobm
Apologies for the confusion. With the implementation of virtual fields, the issue with the sort buttons on the index screen became more noticeable, so I initially included both in the same PR.
I've now split the PRs. I'll address the points you mentioned later, and I'd appreciate your review again at that time.

@goosys
Copy link
Contributor Author

goosys commented Sep 13, 2024

@pablobm

Also, I see that your code would only work when when @data.nil?. This could lead to unexpected behaviour, as it's perfectly normal for a non-virtual field to be nil instead of having a value. In my mind, it goes "if it's nil, perhaps it's a virtual field".

This is for compatibility reasons.
We can fetch data exclusively from within the field using read_value. While the application will work perfectly in this state, many RSpec tests fail because they insert data using the data argument.

module Administrate
  module Field
    class Base
      def initialize(attribute, _data, page, options = {})
        @attribute = attribute
        @page = page
        @resource = options.delete(:resource)
        @options = options
        @data = read_value
      end

image

Should I fix all the RSpec tests?
In that case, the data argument in initialize method would become unnecessary, so it would be better to either remove it or make it optional. However, this might cause compatibility issues with third-party Fields.

@goosys
Copy link
Contributor Author

goosys commented Sep 14, 2024

@pablobm

You propose an option :getter to define how a field will be accessed. In contrast, the lost functionality allowed the creation of fields that didn't need special options or interfaces.
In my view, the broken functionality was simpler to use and I would prefer that to return.

I believe this case should not be an issue. For custom fields, you can override read_value to fetch data and format it using any method you prefer.
I've written a sample and committed it, so please check it.

a83e2ff

Copy link
Collaborator

@pablobm pablobm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I'm more onboard now. Thank you for the changes and explanation 🙂

Regarding the data argument, for some time I have been thinking that it should disappear, but let's not do that now. This PR can be an intermediate step with back-compatibility, towards a future where fields calculate their value instead of being calculated at the Page level.

I wasn't sure about the :getter, but after playing with it a bit I'm liking it better. I think it can stay.

Comment on lines 62 to 66
else
if data.nil?
resource&.public_send(attribute)
else
data
end
end
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, the example I mentioned with Foobar doesn't work: public_send fails because the method doesn't exist in the resource. Note that the safe navigation operator (&.) doesn't help here because it's useful only when the resource is nil.

However, I think it can be fixed with a respond_to? instead, as below:

Suggested change
else
if data.nil?
resource&.public_send(attribute)
else
data
end
end
elsif data.nil?
resource.respond_to?(attribute) ? resource.public_send(attribute) : nil
else
data
end
end

This should also fix the linter error asking for elsif to be used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for the oversight. I’ve made the fixes and checked with standardrb.
I reviewed the your reference code and felt that try would be sufficient, so I rewrote it using try. Do you have any concerns about this approach?

Comment on lines 11 to 14
nickname: Field::String.with_options(
getter: ->(field) { "Mr. #{field.resource.name}man" if field.resource.name.present? },
searchable: false
),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking of an example that doesn't feel too artificial: how about a full_address field for Order? Something like this:

    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")
      },
      searchable: false
    ),

Then its SHOW_PAGE_ATTRIBUTES can be something like this:

  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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I added.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like searchable: false is excess. If getter has been provided it makes field as unsearchable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you thought about using decorators(like draper) for virtual fields?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, should have been more clear: remove this example (which I find too artificial) and we can leave the one for the orders, which I think is better 🙂

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point on searchable. Perhaps it should be false by default if getter is supplied? Otherwise it breaks the search.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Nitr - It's a bit tricky due to how Administrate works internally. Also we tend to avoid adding dependencies like draper.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Nitr @pablobm
Thank you for the advice. I have made the changes. cab6096
I'm not sure if we should always set searchable to false when a getter is present, but since I can't think of any use cases at the moment, this seems appropriate.


module Administrate
module Field
class VirtualField < Field::Base
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can find an example in the dashboard where we can define this type of virtual field without having to drop it here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What else is needed here? Documentation, maybe?
Or would an approach using an option like virtual_field: true to explicitly indicate virtual fields work better?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answering on the main thread 🙂

@goosys goosys force-pushed the issue-1586-virtual-fields branch from a83e2ff to a575bde Compare September 25, 2024 18:26
Copy link
Collaborator

@pablobm pablobm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming along nicely! Let's see...

Could you please add documentation about the new :getter option?

Then about the spec/lib/fields/virtual_field_spec.rb spec. I think best to remove it completely and a good example like the following:

+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 00000000..9f755667
--- /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/lib/administrate/field/receipt_link.rb b/spec/example_app/lib/administrate/field/receipt_link.rb
new file mode 100644
index 00000000..0756b259
--- /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}.pdf"
+      end
+    end
+  end
+end

Still needs a _show template and a simple spec (doesn't need to be big). Could you please add that?

Comment on lines 11 to 14
nickname: Field::String.with_options(
getter: ->(field) { "Mr. #{field.resource.name}man" if field.resource.name.present? },
searchable: false
),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, should have been more clear: remove this example (which I find too artificial) and we can leave the one for the orders, which I think is better 🙂

Comment on lines 11 to 14
nickname: Field::String.with_options(
getter: ->(field) { "Mr. #{field.resource.name}man" if field.resource.name.present? },
searchable: false
),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point on searchable. Perhaps it should be false by default if getter is supplied? Otherwise it breaks the search.

Comment on lines 11 to 14
nickname: Field::String.with_options(
getter: ->(field) { "Mr. #{field.resource.name}man" if field.resource.name.present? },
searchable: false
),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Nitr - It's a bit tricky due to how Administrate works internally. Also we tend to avoid adding dependencies like draper.

spec/lib/fields/base_spec.rb Outdated Show resolved Hide resolved

module Administrate
module Field
class VirtualField < Field::Base
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answering on the main thread 🙂

@goosys
Copy link
Contributor Author

goosys commented Sep 29, 2024

Next, I would like to add documentation.
However, it seems like this feature has become more complex and capable than I initially expected, so I plan to organize it a bit before documenting it. If possible, I would appreciate your continued assistance.

@pablobm
Copy link
Collaborator

pablobm commented Sep 29, 2024

@goosys - Sounds good 🙂

@goosys
Copy link
Contributor Author

goosys commented Oct 4, 2024

@pablobm
I've created documentation for all the use cases I can think of at the moment. Do you think there are any other use cases we should include?
d1fd3a9

Also, if there are any mistakes in the English within the documentation, I’d appreciate it if you could correct them. Thank you!

@Nitr
Copy link
Contributor

Nitr commented Oct 7, 2024

@goosys
For all field types - I guess it won't work properly for associative types. It will try to includes association by key to avoid N+1 and it will fail.

@goosys
Copy link
Contributor Author

goosys commented Oct 8, 2024

@Nitr
Thanks for the valuable feedback!
I checked, and it seems to be working for now. Although multiple options besides the :getter need to be specified, nothing seems to be broken. (Also, it appears that the deprecated :class_name is still required.)
Regarding the N+1, it can be avoided with the existing :includes option.

class CustomerDashboard < Administrate::BaseDashboard
  ATTRIBUTE_TYPES = {
    orders: Field::HasMany.with_options(limit: 2, sort_by: :id),
    recent_orders: Field::HasMany.with_options(
      getter: ->(field){ field.resource.orders.where(created_at: [3.days.ago...]).order(created_at: :desc).limit(2) },
      class_name: "Order",
      foreign_key: :customer_id,
      includes: :orders
    ),

It’s quite strange at this point, but I think we can improve it going forward. What do you think?

@pablobm
Copy link
Collaborator

pablobm commented Oct 18, 2024

I think this is it? (Apart from the two small suggestions now). Do you think there's anything else to cover or should we go and merge?

Tangentially: I need to un-deprecate the :class_name option as we have come across legitimate use cases.

docs/customizing_dashboards.md Outdated Show resolved Hide resolved
docs/customizing_dashboards.md Show resolved Hide resolved
Comment on lines 206 to 208
end

context "when given a :getter block" do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit can be deleted as the two contexts are the same.

Suggested change
end
context "when given a :getter block" do
end
context "when given a :getter block" do

Was this part of my suggestion the other day? 😳

goosys and others added 2 commits October 22, 2024 23:07
Co-authored-by: Pablo Brasero <36066+pablobm@users.noreply.github.com>
@goosys
Copy link
Contributor Author

goosys commented Oct 22, 2024

@pablobm

I think this is it? (Apart from the two small suggestions now). Do you think there's anything else to cover or should we go and merge?

I believe there are still plenty of ways we could use this, depending on new ideas. But for now, I think it’s fine to go ahead and merge it.

@pablobm
Copy link
Collaborator

pablobm commented Oct 24, 2024

One last thing. Sorry! 😓

  1. I saw a couple of read_value that I don't think are used anywhere and removed them. Also removed the default value data = nil from the normal read_value.
  2. I added some nicety to the receipt downloader.

Changes are at main...pablobm:administrate:goosys-issue-1586-virtual-fields. Does this make sense? If so, could you please merge my two commits into your branch?

@goosys
Copy link
Contributor Author

goosys commented Oct 24, 2024

@pablobm
Thank you for checking.

It seems the Deferred#read_value isn’t necessary. I wasn’t entirely sure, so I added it just in case. Sorry about that.
Also, thanks for the other additions! I’ve merged the changes.

One thing I wanted to mention—while pushing to this PR several times, I noticed that the payment_index_spec was unstable, failing and passing inconsistently only with Ruby 3.0. I addressed it since it was happening quite often.
Could it be that updating current_path is sometimes delayed? It’s strange because no other tests had this issue, just this one file. After modifying it to use have_current_path, it stopped failing locally.

@pablobm
Copy link
Collaborator

pablobm commented Oct 25, 2024

Re: current_path, no idea 😅 I wasn't seeing the problem so I was not aware. But if your change appears to fix things, then that's good 👍

Merging at last! 🚀

@pablobm pablobm merged commit 1eac3d7 into thoughtbot:main Oct 25, 2024
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants