uchi 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/uchi/application.js +384 -317
  3. data/app/assets/stylesheets/uchi/application.css +8 -0
  4. data/app/assets/tailwind/uchi.css +5 -0
  5. data/app/components/uchi/field/base/show.html.erb +1 -0
  6. data/app/components/uchi/field/base.rb +24 -27
  7. data/app/components/uchi/field/boolean/index.html.erb +9 -7
  8. data/app/components/uchi/field/boolean/show.html.erb +9 -7
  9. data/app/components/uchi/field/file/show.html.erb +1 -1
  10. data/app/components/uchi/field/file.rb +1 -1
  11. data/app/components/uchi/field/has_and_belongs_to_many.rb +19 -1
  12. data/app/components/uchi/field/has_many.rb +20 -0
  13. data/app/components/uchi/field/id/show.html.erb +1 -1
  14. data/app/components/uchi/field/image/index.html.erb +1 -1
  15. data/app/components/uchi/field/image/show.html.erb +2 -2
  16. data/app/components/uchi/field/image.rb +1 -1
  17. data/app/components/uchi/field/select/edit.html.erb +1 -0
  18. data/app/components/uchi/field/select/index.html.erb +1 -0
  19. data/app/components/uchi/field/select/show.html.erb +1 -0
  20. data/app/components/uchi/field/select.rb +144 -0
  21. data/app/components/uchi/field/text.rb +1 -1
  22. data/app/components/uchi/ui/index/records_table/records_table.html.erb +1 -1
  23. data/app/controllers/uchi/repository_controller.rb +2 -2
  24. data/app/views/layouts/uchi/_javascript.html.erb +1 -1
  25. data/app/views/layouts/uchi/application.html.erb +4 -6
  26. data/app/views/uchi/has_many/associated_records/index.html.erb +6 -4
  27. data/app/views/uchi/repository/edit.html.erb +1 -1
  28. data/app/views/uchi/repository/new.html.erb +1 -1
  29. data/app/views/uchi/repository/show.html.erb +4 -4
  30. data/lib/uchi/field/configuration.rb +42 -5
  31. data/lib/uchi/repository.rb +19 -7
  32. data/lib/uchi/sort_order.rb +9 -4
  33. data/lib/uchi/version.rb +1 -1
  34. metadata +6 -5
  35. data/app/components/uchi/field/date/show.html.erb +0 -1
  36. data/app/components/uchi/field/number/show.html.erb +0 -1
  37. data/app/components/uchi/field/string/show.html.erb +0 -1
  38. data/app/components/uchi/field/text/show.html.erb +0 -1
@@ -669,6 +669,9 @@
669
669
  text-overflow: ellipsis;
670
670
  white-space: nowrap;
671
671
  }
672
+ .overflow-hidden {
673
+ overflow: hidden;
674
+ }
672
675
  .overflow-x-auto {
673
676
  overflow-x: auto;
674
677
  }
@@ -1339,6 +1342,11 @@
1339
1342
  }
1340
1343
  }
1341
1344
  }
1345
+ .md\:p-0 {
1346
+ @media (width >= 48rem) {
1347
+ padding: calc(var(--spacing) * 0);
1348
+ }
1349
+ }
1342
1350
  .md\:px-0 {
1343
1351
  @media (width >= 48rem) {
1344
1352
  padding-inline: calc(var(--spacing) * 0);
@@ -19,3 +19,8 @@ running the following command:
19
19
 
20
20
  /* Don't include classes used in docs into Uchis stylesheet */
21
21
  @source not "docs";
22
+
23
+ /* Don't include classes from the the Tailwind-generated stylesheet into the
24
+ list of classes to preserve in the Tailwind-generated stylesheet. Doing so means
25
+ that classes would never be purged */
26
+ @source not "app/assets/stylesheets/uchi/application.css";
@@ -0,0 +1 @@
1
+ <%= value %>
@@ -3,39 +3,40 @@
3
3
  module Uchi
4
4
  class Field
5
5
  class Base < Field
6
- # Uchi::Field::Base::Edit components render fields in the edit view.
7
- class Edit < ViewComponent::Base
8
- attr_reader :field, :form, :record, :repository, :label, :hint
6
+ class Component < ViewComponent::Base
7
+ attr_reader :field, :record, :repository
9
8
 
10
- def initialize(field:, form:, repository:, label: nil, hint: nil)
9
+ def initialize(field:, record:, repository:)
11
10
  super()
12
11
 
13
12
  @field = field
14
- @form = form
15
- @label = label
16
- @hint = hint
17
- @record = form.object
13
+ @record = record
18
14
  @repository = repository
19
15
  end
20
- end
21
16
 
22
- # Uchi::Field::Base::Show components render fields in the show view.
23
- class Show < ViewComponent::Base
24
- attr_reader :field, :record, :repository
17
+ # Returns the raw value of the field as returned by the field's value
18
+ # method.
19
+ def value
20
+ return @value if instance_variable_defined?(:@value)
25
21
 
26
- def initialize(field:, record:, repository:)
27
- super()
22
+ @value = field.value(record)
23
+ end
24
+ end
28
25
 
29
- @field = field
30
- @record = record
31
- @repository = repository
26
+ # Uchi::Field::Base::Edit components render fields in the edit view.
27
+ class Edit < Component
28
+ attr_reader :form, :label, :hint
29
+
30
+ def initialize(field:, form:, repository:, label: nil, hint: nil)
31
+ super(field:, record: form.object, repository:)
32
+ @form = form
33
+ @label = label
34
+ @hint = hint
32
35
  end
33
36
  end
34
37
 
35
38
  # Uchi::Field::Base::Index components render fields in the index view.
36
- class Index < ViewComponent::Base
37
- attr_reader :field, :record, :repository
38
-
39
+ class Index < Component
39
40
  class << self
40
41
  # Returns the CSS classes to apply to the td or th of the table where
41
42
  # this field is rendered.
@@ -43,14 +44,10 @@ module Uchi
43
44
  []
44
45
  end
45
46
  end
47
+ end
46
48
 
47
- def initialize(field:, record:, repository:)
48
- super()
49
-
50
- @field = field
51
- @record = record
52
- @repository = repository
53
- end
49
+ # Uchi::Field::Base::Show components render fields in the show view.
50
+ class Show < Component
54
51
  end
55
52
  end
56
53
  end
@@ -1,9 +1,11 @@
1
- <% if field.value(record) %>
2
- <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
1
+ <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
2
+ <% if field.value(record) %>
3
+
3
4
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
4
- </svg>
5
- <% else %>
6
- <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
5
+
6
+ <% else %>
7
+
7
8
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.757 12h8.486M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
8
- </svg>
9
- <% end %>
9
+
10
+ <% end %>
11
+ </svg>
@@ -1,9 +1,11 @@
1
- <% if field.value(record) %>
2
- <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
1
+ <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
2
+ <% if value %>
3
+
3
4
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
4
- </svg>
5
- <% else %>
6
- <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
5
+
6
+ <% else %>
7
+
7
8
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.757 12h8.486M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
8
- </svg>
9
- <% end %>
9
+
10
+ <% end %>
11
+ </svg>
@@ -1,4 +1,4 @@
1
- <% attachment = field.value(record) %>
1
+ <% attachment = value %>
2
2
 
3
3
  <% if attachment.attached? %>
4
4
  <%= render Uchi::Flowbite::Link.new(href: Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)) do %>
@@ -29,7 +29,7 @@ module Uchi
29
29
  false
30
30
  end
31
31
 
32
- def default_sortable?
32
+ def default_sortable
33
33
  false
34
34
  end
35
35
  end
@@ -25,7 +25,7 @@ module Uchi
25
25
 
26
26
  class Show < Uchi::Field::Base::Show
27
27
  def associated_records
28
- records = field.value(record)
28
+ records = value
29
29
 
30
30
  associated_repository.find_all(scope: records)
31
31
  end
@@ -126,6 +126,24 @@ module Uchi
126
126
  def association
127
127
  @association ||= repository.model.reflect_on_association(name)
128
128
  end
129
+
130
+ def default_sortable
131
+ lambda { |query, direction|
132
+ reflection = query.klass.reflect_on_association(name)
133
+ return query unless reflection
134
+
135
+ associated_table = reflection.klass.table_name
136
+ associated_primary_key = reflection.klass.primary_key
137
+ count = Arel.sql("COUNT(#{associated_table}.#{associated_primary_key})")
138
+ primary_key = query.klass.arel_table[query.klass.primary_key]
139
+
140
+ SortOrder.new(count, direction).apply(
141
+ query
142
+ .left_outer_joins(name)
143
+ .group(primary_key)
144
+ ).order(primary_key.asc)
145
+ }
146
+ end
129
147
  end
130
148
  end
131
149
  end
@@ -150,6 +150,26 @@ module Uchi
150
150
  def permitted_param
151
151
  {param_key => []}
152
152
  end
153
+
154
+ protected
155
+
156
+ def default_sortable
157
+ lambda { |query, direction|
158
+ reflection = query.klass.reflect_on_association(name)
159
+ return query unless reflection
160
+
161
+ associated_table = reflection.klass.table_name
162
+ associated_primary_key = reflection.klass.primary_key
163
+ count = Arel.sql("COUNT(#{associated_table}.#{associated_primary_key})")
164
+ primary_key = query.klass.arel_table[query.klass.primary_key]
165
+
166
+ SortOrder.new(count, direction).apply(
167
+ query
168
+ .left_outer_joins(name)
169
+ .group(primary_key)
170
+ ).order(primary_key.asc)
171
+ }
172
+ end
153
173
  end
154
174
  end
155
175
  end
@@ -1,4 +1,4 @@
1
1
  <%= render(Uchi::Flowbite::Link.new(
2
2
  href: @repository.routes.path_for(:show, id: record.id),
3
- target: "_top").with_content(field.value(record))
3
+ target: "_top").with_content(value)
4
4
  ) %>
@@ -1,7 +1,7 @@
1
1
  <% attachment = field.value(record) %>
2
2
 
3
3
  <% if attachment.attached? %>
4
- <%= image_tag attachment.variant(resize_to_limit: [100, 100]), class: "h-12 w-12 object-cover rounded" %>
4
+ <%= image_tag attachment.variant(resize_to_limit: [100, 100]), alt: repository.translate.field_label(field), class: "h-12 w-12 object-cover rounded" %>
5
5
  <% else %>
6
6
  <%= render(Uchi::Ui::NoValue.new) %>
7
7
  <% end %>
@@ -1,7 +1,7 @@
1
- <% attachment = field.value(record) %>
1
+ <% attachment = value %>
2
2
 
3
3
  <% if attachment.attached? %>
4
- <%= image_tag attachment, class: "max-w-full h-auto rounded-lg shadow-md" %>
4
+ <%= image_tag attachment, alt: repository.translate.field_label(field),class: "max-w-full h-auto rounded-lg shadow-md" %>
5
5
  <% else %>
6
6
  <%= render(Uchi::Ui::NoValue.new) %>
7
7
  <% end %>
@@ -30,7 +30,7 @@ module Uchi
30
30
  false
31
31
  end
32
32
 
33
- def default_sortable?
33
+ def default_sortable
34
34
  false
35
35
  end
36
36
  end
@@ -0,0 +1 @@
1
+ <%= render(Uchi::Flowbite::InputField::Select.new(**options)) %>
@@ -0,0 +1 @@
1
+ <%= field.label_for(field.value(record)) %>
@@ -0,0 +1 @@
1
+ <%= field.label_for(value) %>
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uchi
4
+ class Field
5
+ class Select < Field
6
+ class Edit < Uchi::Field::Base::Edit
7
+ def collection
8
+ if field.grouped?
9
+ field.options.map { |group, group_options| [group, group_options.map { |value, label| [label, value] }] }
10
+ else
11
+ field.flat_options.map { |value, label| [label, value] }
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def options
18
+ options = {
19
+ attribute: field.name,
20
+ collection: collection,
21
+ form: form,
22
+ label: {content: label}
23
+ }
24
+ options[:hint] = {content: hint} if hint.present?
25
+ options
26
+ end
27
+ end
28
+
29
+ class Index < Uchi::Field::Base::Index
30
+ end
31
+
32
+ class Show < Uchi::Field::Base::Show
33
+ end
34
+
35
+ # Resolves and memoizes the options as a flat value => label Hash, keyed
36
+ # by the string representation of each option's value so #label_for
37
+ # can look up a label in constant time. Memoized so that a Proc given
38
+ # to #options is only called once per field instance - see
39
+ # #resolved_options for details. The memo is cleared whenever #options
40
+ # is called with a new value.
41
+ #
42
+ # When the same value appears in more than one group, the first
43
+ # matching label wins - matching how a browser's `<select>` element
44
+ # only ever selects the first option with a matching value.
45
+ def flat_options
46
+ @flat_options ||= resolved_options.each_with_object({}) { |(key, value), flat|
47
+ if value.is_a?(Hash)
48
+ value.each { |group_key, group_label| flat[group_key.to_s] = group_label unless flat.key?(group_key.to_s) }
49
+ else
50
+ flat[key.to_s] = value unless flat.key?(key.to_s)
51
+ end
52
+ }
53
+ end
54
+
55
+ # Returns true if the configured options are grouped, ie. a non-empty
56
+ # Hash whose values are all themselves Hashes of options.
57
+ def grouped?
58
+ resolved_options.present? && resolved_options.values.all? { |value| value.is_a?(Hash) }
59
+ end
60
+
61
+ def initialize(name)
62
+ super
63
+ @options = {}
64
+ end
65
+
66
+ # Returns the label to display for the given value, or nil if the value
67
+ # doesn't match any of the configured options.
68
+ #
69
+ # Values are compared using their string representation, matching how
70
+ # a `<select>` element compares its options' values against the
71
+ # persisted attribute value.
72
+ def label_for(value)
73
+ return if value.nil?
74
+
75
+ flat_options[value.to_s]
76
+ end
77
+
78
+ # Sets or gets the options to choose between.
79
+ #
80
+ # When called with an argument, sets the options and returns self for
81
+ # chaining. When called without arguments, returns the current options,
82
+ # resolved to a Hash.
83
+ #
84
+ # @param options [Hash, Array, Proc] A hash mapping stored values to
85
+ # their human-readable labels. An array can be given instead, in which
86
+ # case each item is used as both the stored value and its label. A
87
+ # proc can also be given; it's called with no arguments and should
88
+ # return a Hash or an Array as described above.
89
+ #
90
+ # To group options, pass a Hash whose values are themselves a Hash or
91
+ # Array of options, keyed by the group label.
92
+ # @return [self, Hash] Returns self for method chaining when setting,
93
+ # or the options hash when getting
94
+ #
95
+ # @example Setting with a Hash
96
+ # Field::Select.new(:size).options({s: "Small", m: "Medium", l: "Large"})
97
+ #
98
+ # @example Setting with an Array
99
+ # Field::Select.new(:size).options(["Small", "Medium", "Large"])
100
+ #
101
+ # @example Setting with a Proc
102
+ # Field::Select.new(:size).options(-> { Size.pluck(:key, :name).to_h })
103
+ #
104
+ # @example Setting grouped options
105
+ # Field::Select.new(:size).options({
106
+ # "Letters" => {s: "Small", m: "Medium", l: "Large"},
107
+ # "Numbers" => ["32", "34", "36"]
108
+ # })
109
+ #
110
+ # @example Getting
111
+ # field.options # => {s: "Small", m: "Medium", l: "Large"}
112
+ def options(options = Configuration::Unset)
113
+ return resolved_options if options == Configuration::Unset
114
+
115
+ @options = options
116
+ @resolved_options = nil
117
+ @flat_options = nil
118
+ self
119
+ end
120
+
121
+ private
122
+
123
+ def normalize(options)
124
+ case options
125
+ when Array
126
+ options.to_h { |option| [option, option] }
127
+ when Hash
128
+ options.transform_values { |value| value.is_a?(Array) ? value.to_h { |option| [option, option] } : value }
129
+ else
130
+ raise ArgumentError, "Field::Select options must be a Hash, an Array, or a Proc returning one of those, got #{options.class}"
131
+ end
132
+ end
133
+
134
+ # Resolves and memoizes the configured options. Memoized so that a Proc
135
+ # given to #options is only called once per field instance, even though
136
+ # #options, #grouped?, and #label_for are all called multiple times
137
+ # while rendering a single page (e.g. once per row on an index page).
138
+ # The memo is cleared whenever #options is called with a new value.
139
+ def resolved_options
140
+ @resolved_options ||= normalize(@options.respond_to?(:call) ? @options.call : @options)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -27,7 +27,7 @@ module Uchi
27
27
  protected
28
28
 
29
29
  def default_on
30
- [:edit, :show]
30
+ [:edit, :new, :show]
31
31
  end
32
32
 
33
33
  def default_searchable?
@@ -15,7 +15,7 @@
15
15
  class="px-6 py-3 font-medium whitespace-nowrap <%= component_class.classes_for_table_cell.join(" ") %>"
16
16
  >
17
17
  <% if field.sortable? %>
18
- <% if field.name == sort_order&.name %>
18
+ <% if field.name == sort_order&.column %>
19
19
  <% if sort_order&.ascending? %>
20
20
  <%= link_to(repository.routes.path_for(:index, query: query, scope: scope, sort: {:by => field.name, :direction => :desc})) do %>
21
21
  <%= repository.translate.field_label(field) %>
@@ -129,11 +129,11 @@ module Uchi
129
129
  end
130
130
 
131
131
  def permitted_params_for_edit
132
- @repository.fields_for_edit.map(&:permitted_param)
132
+ @repository.fields_for_edit(record: @record).map(&:permitted_param)
133
133
  end
134
134
 
135
135
  def permitted_params_for_new
136
- @repository.fields_for_new.map(&:permitted_param)
136
+ @repository.fields_for_new(record: @record || @repository.build).map(&:permitted_param)
137
137
  end
138
138
 
139
139
  def record_params_for_edit
@@ -1 +1 @@
1
- <%= javascript_include_tag "uchi/application", "data-turbo-track": "reload" %>
1
+ <%= javascript_include_tag "uchi/application", "data-turbo-track": "reload", nonce: true %>
@@ -1,6 +1,6 @@
1
1
  <!DOCTYPE html>
2
2
 
3
- <html class="h-full">
3
+ <html lang="en">
4
4
  <head>
5
5
  <title>
6
6
  <%= content_for?(:page_title) ? yield(:page_title) : "Uchi" %>
@@ -15,12 +15,10 @@
15
15
  <%= render "layouts/uchi/stylesheets" %>
16
16
  </head>
17
17
 
18
- <body class="antialiased bg-neutral-secondary-medium h-full p-2">
19
- <div class="md:flex h-full">
20
- <%= render(partial: "uchi/navigation/main") %>
18
+ <body class="antialiased bg-neutral-secondary-medium p-2 h-screen overflow-hidden md:flex md:p-0">
19
+ <%= render(partial: "uchi/navigation/main") %>
21
20
 
22
- <main class="py-6 overflow-x-hidden grow md:px-6"><%= yield %></main>
23
- </div>
21
+ <main class="py-6 overflow-x-hidden grow md:px-6"><%= yield %></main>
24
22
 
25
23
  <%= render "layouts/uchi/flash_messages" %>
26
24
  </body>
@@ -1,4 +1,4 @@
1
- <ul class="p-4 text-sm text-body font-medium space-y-4">
1
+ <ul class="p-4 space-y-4 text-sm font-medium text-body">
2
2
  <% @records.each do |record| %>
3
3
  <%
4
4
  checkbox_id = dom_id(record, [source_repository.model, @field_name, 'checkbox'].join("-"))
@@ -12,12 +12,14 @@
12
12
  <input
13
13
  type="checkbox"
14
14
  id="<%= checkbox_id %>"
15
- class="w-4 h-4 border border-default-strong rounded-xs bg-neutral-secondary-strong focus:ring-2 focus:ring-brand-soft cursor-pointer"
15
+ class="w-4 h-4 border cursor-pointer border-default-strong rounded-xs bg-neutral-secondary-strong focus:ring-2 focus:ring-brand-soft"
16
16
  data-action="change->has-many#handleCheckboxChange"
17
17
  data-has-many-target="checkbox"
18
- <%= 'checked' if @current_values.include?(record) %>
18
+ <% if @current_values.include?(record) %>
19
+ checked
20
+ <% end %>
19
21
  >
20
- <label for="<%= checkbox_id %>" class="ms-2 text-sm font-medium text-heading cursor-pointer">
22
+ <label for="<%= checkbox_id %>" class="text-sm font-medium cursor-pointer ms-2 text-heading">
21
23
  <%= record_title(record) %>
22
24
  </label>
23
25
  </div>
@@ -17,7 +17,7 @@
17
17
  <%= render(Uchi::Flowbite::Card.new) do %>
18
18
  <%= form_with model: @record, url: @repository.routes.path_for(:update, id: @record.id) do |form| %>
19
19
  <div class="space-y-6">
20
- <% @repository.fields_for_edit.each do |field| %>
20
+ <% @repository.fields_for_edit(record: @record).each do |field| %>
21
21
  <div>
22
22
  <%= render(
23
23
  field.edit_component(
@@ -15,7 +15,7 @@
15
15
  <%= render(Uchi::Flowbite::Card.new) do %>
16
16
  <%= form_with model: @record, url: @repository.routes.path_for(:create) do |form| %>
17
17
  <div class="space-y-6">
18
- <% @repository.fields_for_new.each do |field| %>
18
+ <% @repository.fields_for_new(record: @record).each do |field| %>
19
19
  <div>
20
20
  <%= render(
21
21
  field.new_component(
@@ -39,14 +39,14 @@
39
39
  ]) %>
40
40
  <% end %>
41
41
 
42
+ <% visible_fields = @repository.fields_for_show(record: @record) %>
43
+
42
44
  <%= render(Uchi::Ui::Show::AttributeFields.new(
43
- attribute_fields: @repository.fields_for_show.select { |field| field.group_as(:show) == :attributes },
45
+ attribute_fields: visible_fields.select { |field| field.group_as(:show) == :attributes },
44
46
  record: @record,
45
47
  repository: @repository
46
48
  )) %>
47
49
 
48
- <% association_fields = @repository.fields_for_show.select{ |field| field.group_as(:show) == :associations } %>
49
-
50
- <% association_fields.each do |field| %>
50
+ <% visible_fields.select { |field| field.group_as(:show) == :associations }.each do |field| %>
51
51
  <%= render(field.show_component(record: @record, repository: @repository)) %>
52
52
  <% end %>
@@ -6,13 +6,15 @@ module Uchi
6
6
  class Unset; end
7
7
 
8
8
  DEFAULT_READER = ->(record, field_name) { record&.public_send(field_name) }
9
+ DEFAULT_VISIBLE = ->(_record) { true }
9
10
 
10
11
  def initialize(*args)
11
12
  super
12
13
  @on = default_on
13
14
  @reader = DEFAULT_READER
14
15
  @searchable = default_searchable?
15
- @sortable = default_sortable?
16
+ @visible = DEFAULT_VISIBLE
17
+ @sortable = default_sortable
16
18
  end
17
19
 
18
20
  # Sets or gets which actions this field should appear on.
@@ -89,6 +91,32 @@ module Uchi
89
91
  !!@searchable
90
92
  end
91
93
 
94
+ # Sets or gets a conditional proc that determines whether this field should
95
+ # be visible for a given record.
96
+ #
97
+ # When called with a proc argument, sets the visibility condition and returns
98
+ # self for chaining. When called without arguments, returns the current proc.
99
+ #
100
+ # @param visible_proc [Proc] A callable that receives the record and returns
101
+ # a boolean indicating whether the field should be visible.
102
+ # Raises ArgumentError for non-callables.
103
+ # @return [self, Proc] Returns self for method chaining when setting,
104
+ # or the current proc when getting
105
+ #
106
+ # @example Setting
107
+ # Field::String.new(:id).visible(lambda { |record| record.id.even? })
108
+ #
109
+ # @example Getting
110
+ # field.visible # => #<Proc...>
111
+ def visible(visible_proc = Configuration::Unset)
112
+ return @visible if visible_proc == Configuration::Unset
113
+
114
+ raise ArgumentError, "visible must be callable" unless visible_proc.respond_to?(:call)
115
+
116
+ @visible = visible_proc
117
+ self
118
+ end
119
+
92
120
  # Sets or gets whether and how this field is sortable.
93
121
  #
94
122
  # When called with an argument, sets sortable and returns self for chaining.
@@ -111,7 +139,7 @@ module Uchi
111
139
  # @example Getting
112
140
  # field.sortable # => true
113
141
  def sortable(value = Configuration::Unset)
114
- return @sortable if value == Configuration::Unset
142
+ return @sortable.nil? ? default_sortable : @sortable if value == Configuration::Unset
115
143
 
116
144
  @sortable = value
117
145
  self
@@ -119,9 +147,18 @@ module Uchi
119
147
 
120
148
  # Returns true if the field is sortable
121
149
  def sortable?
122
- return default_sortable? if @sortable.nil?
150
+ !!sortable
151
+ end
123
152
 
124
- !!@sortable
153
+ # Returns whether this field should be visible for the given record.
154
+ #
155
+ # Calls the visible proc with the record. Defaults to DEFAULT_VISIBLE,
156
+ # which always returns true.
157
+ #
158
+ # @param record [Object] The record to check visibility for
159
+ # @return [Boolean] Whether the field should be visible
160
+ def visible_for?(record)
161
+ !!@visible.call(record)
125
162
  end
126
163
 
127
164
  protected
@@ -134,7 +171,7 @@ module Uchi
134
171
  false
135
172
  end
136
173
 
137
- def default_sortable?
174
+ def default_sortable
138
175
  true
139
176
  end
140
177
  end