admin_resources 0.2.2 → 0.2.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbfcb542eb70f7590c00d41533ac2b351a4359ad5ee6e20c920a2276253091e2
4
- data.tar.gz: ffee4ff37a690d5f291d0aee47ed7c2b16a1c00ad35d337491ff80374488244f
3
+ metadata.gz: ab15134c08ae8f5094ad4aaff7993db9e87ba6f2ae68301230ab08077e1014ca
4
+ data.tar.gz: a9e1c2480b2ab1458e94411421d17ab9fd109eb3a7aa971e4cc41a9d677d253a
5
5
  SHA512:
6
- metadata.gz: 3cea885b9b6c4c774eb3924c8f8ead907a62ad8004d45ac477cc1d27bc0184affe7a7573347ac568d180114088ed2b8b2f12b232e890449f3472a2f93a2dd7a3
7
- data.tar.gz: 680888318eaeda7c0ab8ba92654f7fbbee1a7a1ba37195f134607a42d9462196bc2e09830203e022548c71064ff8816831dd6abfc3fb64e6fe81a904e1ca200e
6
+ metadata.gz: 4acb95d1ea8994ee3c20ec9b19e5cbb70fee88efa77a0f8bb9b0eb780e41a9f67fae66ec64f6003da4194e26b633d94ad4ac5c4d6e2dfef0dbd48c69675376b1
7
+ data.tar.gz: ee66d605c642c8489e80ba5a7efad43f20e1ca8b82fec790ff9d6b6e2df2e1089ba286ab443cba21449f2263f011d654a71caf972a66f55aa70315b311d9e1a4
@@ -11,7 +11,11 @@ module AdminResources
11
11
  admin_resources.new_admin_user_session_path
12
12
  end
13
13
 
14
- helper_method :admin_models, :admin_path_for
14
+ helper_method :admin_models, :admin_path_for, :admin_custom_pages
15
+
16
+ def admin_custom_pages
17
+ AdminResources.configuration.custom_pages
18
+ end
15
19
 
16
20
  def admin_models
17
21
  AdminResources.model_names
@@ -1,13 +1,13 @@
1
1
  module AdminResources
2
2
  class ResourcesController < ApplicationController
3
3
  before_action :set_model_class
4
- before_action :set_resource, only: %i[show edit update destroy]
4
+ before_action :set_resource, only: %i[show edit update destroy custom_action]
5
5
 
6
- helper_method :model_class, :model_name, :index_columns, :form_columns, :admin_value_display
6
+ helper_method :model_class, :model_name, :index_columns, :form_columns, :admin_value_display, :join_associations, :custom_actions, :filter_columns, :attachment_reflections
7
7
 
8
8
  def index
9
9
  puts "[AdminResources::ResourcesController] index for #{model_name}"
10
- @resources = model_class.all.order(id: :desc)
10
+ @resources = filter_resources(model_class.all).order(id: :desc)
11
11
  end
12
12
 
13
13
  def show
@@ -23,6 +23,8 @@ module AdminResources
23
23
  puts "[AdminResources::ResourcesController] create #{model_name}"
24
24
  @resource = model_class.new(resource_params)
25
25
  if @resource.save
26
+ sync_join_associations
27
+ flash[:new_url] = admin_path_for(model_name, :new)
26
28
  redirect_to admin_path_for(model_name, :show, @resource), notice: "#{model_name} was successfully created."
27
29
  else
28
30
  render :new, status: :unprocessable_entity
@@ -36,6 +38,7 @@ module AdminResources
36
38
  def update
37
39
  puts "[AdminResources::ResourcesController] update #{model_name}##{@resource.id}"
38
40
  if @resource.update(resource_params)
41
+ sync_join_associations
39
42
  redirect_to admin_path_for(model_name, :show, @resource), notice: "#{model_name} was successfully updated."
40
43
  else
41
44
  render :edit, status: :unprocessable_entity
@@ -48,6 +51,33 @@ module AdminResources
48
51
  redirect_to admin_path_for(model_name, :index), notice: "#{model_name} was successfully deleted."
49
52
  end
50
53
 
54
+ # Dispatches to a host-app controller action via a configured custom action.
55
+ # The host app must define a route that this proxies to, specified as `handler`.
56
+ # If no handler is configured, falls back to calling a same-named method on the resource.
57
+ def custom_action
58
+ action_name_param = params[:custom_action]
59
+ action_config = custom_actions.find { |a| a[:name].to_s == action_name_param }
60
+ unless action_config
61
+ redirect_to admin_path_for(model_name, :show, @resource), alert: "Unknown action."
62
+ return
63
+ end
64
+
65
+ handler = action_config[:handler]
66
+ if handler
67
+ # handler is a callable (proc/lambda) that receives (resource, controller)
68
+ handler.call(@resource, self)
69
+ else
70
+ # Default: call a method by the action name on the resource
71
+ if @resource.respond_to?(action_name_param)
72
+ @resource.public_send(action_name_param)
73
+ redirect_to admin_path_for(model_name, :show, @resource),
74
+ notice: "#{action_config[:label] || action_name_param} completed."
75
+ else
76
+ redirect_to admin_path_for(model_name, :show, @resource), alert: "Action not implemented."
77
+ end
78
+ end
79
+ end
80
+
51
81
  private
52
82
 
53
83
  def set_model_class
@@ -79,6 +109,12 @@ module AdminResources
79
109
  model_class.column_names - %w[id created_at updated_at]
80
110
  end
81
111
 
112
+ def attachment_reflections
113
+ return [] unless model_class.respond_to?(:reflect_on_all_attachments)
114
+
115
+ model_class.reflect_on_all_attachments
116
+ end
117
+
82
118
  # Returns [display_text, link_path_or_nil]
83
119
  def admin_value_display(resource, column)
84
120
  unless resource.respond_to?(column)
@@ -92,17 +128,14 @@ module AdminResources
92
128
  assoc_name = column.sub(/_id$/, "")
93
129
  association = resource.class.reflect_on_association(assoc_name.to_sym)
94
130
 
95
- if association && association.macro == :belongs_to
131
+ if association && association.macro == :belongs_to && !association.options[:polymorphic]
96
132
  assoc_class = association.klass
97
133
  assoc_model = assoc_class.name
98
134
 
99
135
  if AdminResources.model_names.include?(assoc_model)
100
136
  associated_record = assoc_class.find_by(id: value)
101
137
  if associated_record
102
- display = "#{assoc_model} ##{value}"
103
- display = associated_record.name if associated_record.respond_to?(:name) && associated_record.name.present?
104
- display = associated_record.version if associated_record.respond_to?(:version) && associated_record.version.present?
105
- display = associated_record.email if associated_record.respond_to?(:email) && associated_record.email.present?
138
+ display = associated_record.to_s
106
139
  return [display, admin_path_for(assoc_model, :show, associated_record)]
107
140
  end
108
141
  end
@@ -121,7 +154,83 @@ module AdminResources
121
154
  col.to_sym
122
155
  end
123
156
  end
157
+ attachment_reflections.each do |reflection|
158
+ if reflection.macro == :has_many_attached
159
+ permitted << { reflection.name => [] }
160
+ else
161
+ permitted << reflection.name
162
+ end
163
+ end
124
164
  params.require(model_class.model_name.param_key).permit(*permitted)
125
165
  end
166
+
167
+ def join_associations
168
+ AdminResources.models[model_name]&.dig(:has_many_through) || []
169
+ end
170
+
171
+ def custom_actions
172
+ AdminResources.models[model_name]&.dig(:custom_actions) || []
173
+ end
174
+
175
+ # Returns only the declared index columns that actually exist as DB columns (excludes virtual/through cols)
176
+ def filter_columns
177
+ index_columns.select { |col| model_class.columns_hash[col] }
178
+ end
179
+
180
+ def filter_resources(scope)
181
+ return scope unless params[:q].is_a?(Hash)
182
+
183
+ params[:q].each do |col, value|
184
+ next if value.blank?
185
+ next unless filter_columns.include?(col)
186
+
187
+ column = model_class.columns_hash[col]
188
+ next unless column
189
+
190
+ case column.type
191
+ when :string, :text, :citext
192
+ scope = scope.where("#{model_class.quoted_table_name}.#{connection.quote_column_name(col)} ILIKE ?", "%#{value}%")
193
+ when :integer, :bigint
194
+ scope = scope.where(col => value.to_i) if value.match?(/\A-?\d+\z/)
195
+ when :boolean
196
+ scope = scope.where(col => ActiveModel::Type::Boolean.new.cast(value)) unless value == "any"
197
+ when :date, :datetime
198
+ scope = scope.where(col => value) if value.present?
199
+ else
200
+ scope = scope.where(col => value)
201
+ end
202
+ end
203
+
204
+ scope
205
+ end
206
+
207
+ def connection
208
+ model_class.connection
209
+ end
210
+
211
+ def sync_join_associations
212
+ join_associations.each do |jdef|
213
+ join_model_class = jdef[:join_model].safe_constantize
214
+ next unless join_model_class
215
+
216
+ foreign_key = jdef[:foreign_key]
217
+ through_key = jdef[:through_key]
218
+ param_key = "#{jdef[:association]}_ids"
219
+ submitted_ids = (params[model_class.model_name.param_key] || {})[param_key]
220
+
221
+ next if submitted_ids.nil?
222
+
223
+ new_ids = Array(submitted_ids).map(&:to_i).reject(&:zero?)
224
+
225
+ existing = join_model_class.where(foreign_key => @resource.id)
226
+ existing_ids = existing.pluck(through_key)
227
+
228
+ to_add = new_ids - existing_ids
229
+ to_remove = existing_ids - new_ids
230
+
231
+ join_model_class.where(foreign_key => @resource.id, through_key => to_remove).destroy_all if to_remove.any?
232
+ to_add.each { |tid| join_model_class.create!(foreign_key => @resource.id, through_key => tid) }
233
+ end
234
+ end
126
235
  end
127
236
  end
@@ -1,4 +1,4 @@
1
- <%= form_with model: @resource, url: (@resource.new_record? ? admin_path_for(model_name, :index) : admin_path_for(model_name, :show, @resource)), class: "admin-form" do |form| %>
1
+ <%= form_with model: @resource, url: (@resource.new_record? ? admin_path_for(model_name, :index) : admin_path_for(model_name, :show, @resource)), class: "admin-form", html: { multipart: attachment_reflections.any? } do |form| %>
2
2
  <% if @resource.errors.any? %>
3
3
  <div class="admin-flash alert">
4
4
  <strong><%= pluralize(@resource.errors.count, "error") %> prohibited this <%= model_name.downcase %> from being saved:</strong>
@@ -20,7 +20,19 @@
20
20
  <% when :text %>
21
21
  <%= form.text_area col, rows: 4 %>
22
22
  <% when :integer, :decimal, :float %>
23
- <%= form.number_field col, step: (column.type == :integer ? 1 : 'any') %>
23
+ <% if model_class.defined_enums.key?(col) %>
24
+ <%= form.select col, model_class.defined_enums[col].keys, include_blank: true %>
25
+ <% elsif col.end_with?('_id') %>
26
+ <% assoc_name = col.sub(/_id$/, '').classify %>
27
+ <% assoc_class = assoc_name.safe_constantize %>
28
+ <% if assoc_class && assoc_class < ActiveRecord::Base %>
29
+ <%= form.collection_select col, assoc_class.all, :id, :to_s, { include_blank: true }, {} %>
30
+ <% else %>
31
+ <%= form.number_field col, step: 1 %>
32
+ <% end %>
33
+ <% else %>
34
+ <%= form.number_field col, step: (column.type == :integer ? 1 : 'any') %>
35
+ <% end %>
24
36
  <% when :date %>
25
37
  <%= form.date_field col %>
26
38
  <% when :datetime %>
@@ -47,6 +59,33 @@
47
59
  </div>
48
60
  <% end %>
49
61
 
62
+ <% attachment_reflections.each do |reflection| %>
63
+ <div class="field">
64
+ <%= form.label reflection.name %>
65
+ <% if reflection.macro == :has_many_attached %>
66
+ <%= form.file_field reflection.name, multiple: true %>
67
+ <% else %>
68
+ <%= form.file_field reflection.name %>
69
+ <% end %>
70
+ </div>
71
+ <% end %>
72
+
73
+ <% join_associations.each do |jdef| %>
74
+ <% assoc_name = jdef[:association] %>
75
+ <% param_key = "#{assoc_name}_ids" %>
76
+ <% target_class = assoc_name.to_s.classify.safe_constantize %>
77
+ <% if target_class && target_class < ActiveRecord::Base %>
78
+ <div class="field">
79
+ <%= form.label param_key, assoc_name.to_s.humanize %>
80
+ <% current_ids = @resource.persisted? ? @resource.send(assoc_name).pluck(:id) : [] %>
81
+ <%= form.collection_select param_key, target_class.all, :id, :to_s,
82
+ { selected: current_ids, include_blank: false },
83
+ { multiple: true, size: 6 } %>
84
+ <small>Hold Ctrl / Cmd to select multiple</small>
85
+ </div>
86
+ <% end %>
87
+ <% end %>
88
+
50
89
  <div class="field">
51
90
  <%= form.submit class: "admin-btn admin-btn-primary" %>
52
91
  <%= link_to "Cancel", (@resource.new_record? ? admin_path_for(model_name) : admin_path_for(model_name, :show, @resource)), class: "admin-btn admin-btn-secondary" %>
@@ -3,6 +3,41 @@
3
3
  <%= link_to "New #{model_name}", admin_path_for(model_name, :new), class: "admin-btn admin-btn-primary" %>
4
4
  </div>
5
5
 
6
+ <% if filter_columns.any? %>
7
+ <form method="get" style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:flex-end;margin-bottom:1.25rem">
8
+ <% filter_columns.each do |col| %>
9
+ <% column = model_class.columns_hash[col] %>
10
+ <div style="display:flex;flex-direction:column;gap:0.25rem">
11
+ <label style="font-size:0.75rem;color:#666"><%= col.titleize %></label>
12
+ <% case column.type %>
13
+ <% when :boolean %>
14
+ <select name="q[<%= col %>]" class="admin-input" style="height:32px;padding:0 0.5rem;font-size:0.875rem">
15
+ <option value="any" <%= "selected" if params.dig(:q, col) == "any" || params.dig(:q, col).blank? %>>Any</option>
16
+ <option value="true" <%= "selected" if params.dig(:q, col) == "true" %>>Yes</option>
17
+ <option value="false" <%= "selected" if params.dig(:q, col) == "false" %>>No</option>
18
+ </select>
19
+ <% when :integer, :bigint %>
20
+ <input type="number" step="1" name="q[<%= col %>]" value="<%= params.dig(:q, col) %>"
21
+ class="admin-input" style="width:100px;height:32px;padding:0 0.5rem;font-size:0.875rem">
22
+ <% when :date %>
23
+ <input type="date" name="q[<%= col %>]" value="<%= params.dig(:q, col) %>"
24
+ class="admin-input" style="height:32px;padding:0 0.5rem;font-size:0.875rem">
25
+ <% when :datetime %>
26
+ <input type="date" name="q[<%= col %>]" value="<%= params.dig(:q, col) %>"
27
+ class="admin-input" style="height:32px;padding:0 0.5rem;font-size:0.875rem">
28
+ <% else %>
29
+ <input type="text" name="q[<%= col %>]" value="<%= params.dig(:q, col) %>"
30
+ placeholder="Filter…" class="admin-input" style="height:32px;padding:0 0.5rem;font-size:0.875rem">
31
+ <% end %>
32
+ </div>
33
+ <% end %>
34
+ <div style="display:flex;gap:0.5rem">
35
+ <button type="submit" class="admin-btn admin-btn-primary" style="height:32px">Filter</button>
36
+ <%= link_to "Clear", request.path, class: "admin-btn admin-btn-secondary", style: "height:32px;line-height:32px;padding:0 0.75rem" %>
37
+ </div>
38
+ </form>
39
+ <% end %>
40
+
6
41
  <table class="admin-table">
7
42
  <thead>
8
43
  <tr>
@@ -14,7 +49,7 @@
14
49
  </thead>
15
50
  <tbody>
16
51
  <% @resources.each do |resource| %>
17
- <tr>
52
+ <tr data-href="<%= admin_path_for(model_name, :show, resource) %>" style="cursor:pointer">
18
53
  <% index_columns.each do |col| %>
19
54
  <td>
20
55
  <% display, link = admin_value_display(resource, col) %>
@@ -31,6 +66,14 @@
31
66
  <td class="admin-actions">
32
67
  <%= link_to "View", admin_path_for(model_name, :show, resource), class: "admin-btn admin-btn-secondary" %>
33
68
  <%= link_to "Edit", admin_path_for(model_name, :edit, resource), class: "admin-btn admin-btn-secondary" %>
69
+ <% custom_actions.each do |action| %>
70
+ <% action_path = "/admin/#{model_name.underscore.pluralize}/#{resource.id}/#{action[:name]}" %>
71
+ <%= button_to action[:label] || action[:name].to_s.titleize,
72
+ action_path,
73
+ method: (action[:method] || :post),
74
+ form: { data: { turbo_confirm: action[:confirm] } },
75
+ class: "admin-btn #{action[:class] || 'admin-btn-secondary'}" %>
76
+ <% end %>
34
77
  </td>
35
78
  </tr>
36
79
  <% end %>
@@ -35,6 +35,38 @@
35
35
  <% end %>
36
36
  </div>
37
37
 
38
+ <%# Display Active Storage attachments %>
39
+ <% model_class.reflect_on_all_associations(:has_many).select { |a| a.options[:class_name] == "ActiveStorage::Attachment" }.each do |assoc| %>
40
+ <% attachments = @resource.send(assoc.name) %>
41
+ <% next if attachments.empty? %>
42
+ <div style="margin-top:2rem">
43
+ <div class="admin-header">
44
+ <h2><%= assoc.name.to_s.sub(/_attachments$/, '').titleize %></h2>
45
+ </div>
46
+ <div class="admin-card" style="display:flex;flex-wrap:wrap;gap:1rem">
47
+ <% attachments.each do |att| %>
48
+ <div style="border:1px solid #e5e5e5;border-radius:6px;overflow:hidden;width:160px">
49
+ <% blob_url = Rails.application.routes.url_helpers.rails_blob_url(att.blob, only_path: true) %>
50
+ <% if att.image? %>
51
+ <%= image_tag blob_url, style: "width:160px;height:120px;object-fit:cover;display:block" %>
52
+ <% elsif att.video? %>
53
+ <video style="width:160px;height:120px;object-fit:cover;display:block" controls preload="none">
54
+ <source src="<%= blob_url %>" type="<%= att.content_type %>">
55
+ </video>
56
+ <% else %>
57
+ <div style="width:160px;height:120px;display:flex;align-items:center;justify-content:center;background:#f5f5f5;font-size:0.75rem;color:#666;text-align:center;padding:0.5rem">
58
+ <%= att.filename %>
59
+ </div>
60
+ <% end %>
61
+ <div style="padding:0.35rem 0.5rem;font-size:0.7rem;color:#666;border-top:1px solid #e5e5e5">
62
+ <%= att.filename %><br><%= number_to_human_size(att.byte_size) %>
63
+ </div>
64
+ </div>
65
+ <% end %>
66
+ </div>
67
+ </div>
68
+ <% end %>
69
+
38
70
  <%# Display has_one associations %>
39
71
  <% model_class.reflect_on_all_associations(:has_one).each do |assoc| %>
40
72
  <% record = @resource.send(assoc.name) %>
@@ -56,15 +56,28 @@
56
56
  class: ('active' if params[:model] == model_name) %>
57
57
  <% end %>
58
58
 
59
+ <% if admin_custom_pages.any? %>
60
+ <h3>Tools</h3>
61
+ <% admin_custom_pages.each do |page| %>
62
+ <%= link_to "#{page[:icon]} #{page[:label]}".strip, page[:path],
63
+ class: (request.path.start_with?(page[:path]) ? 'active' : '') %>
64
+ <% end %>
65
+ <% end %>
66
+
59
67
  <h3>Account</h3>
60
68
  <%= link_to "Admin Users", admin_resources.admin_users_path,
61
69
  class: (controller_path == 'admin_resources/admin_users' ? 'active' : '') %>
62
- <%= link_to "Sign Out", admin_resources.destroy_admin_user_session_path, data: { turbo_method: :delete } %>
70
+ <%= button_to "Sign Out", admin_resources.destroy_admin_user_session_path, method: :delete, class: "admin-btn admin-btn-secondary", style: "width:100%;text-align:left;background:none;color:#eee;padding:0.5rem;border-radius:4px;cursor:pointer;" %>
63
71
  </nav>
64
72
 
65
73
  <main class="admin-main">
66
74
  <% if notice %>
67
- <div class="admin-flash notice"><%= notice %></div>
75
+ <div class="admin-flash notice">
76
+ <%= notice %>
77
+ <% if flash[:new_url] %>
78
+ &nbsp;· <%= link_to "Add another →", flash[:new_url] %>
79
+ <% end %>
80
+ </div>
68
81
  <% end %>
69
82
  <% if alert %>
70
83
  <div class="admin-flash alert"><%= alert %></div>
@@ -73,5 +86,13 @@
73
86
  <%= yield %>
74
87
  </main>
75
88
  </div>
89
+ <script>
90
+ document.addEventListener('click', function(e) {
91
+ var row = e.target.closest('tr[data-href]');
92
+ if (!row) return;
93
+ if (e.target.closest('a, button, form')) return;
94
+ window.location = row.dataset.href;
95
+ });
96
+ </script>
76
97
  </body>
77
98
  </html>
@@ -2,22 +2,35 @@
2
2
 
3
3
  module AdminResources
4
4
  class Configuration
5
- attr_reader :models
5
+ attr_reader :models, :custom_pages
6
6
 
7
7
  def initialize
8
8
  @models = {}
9
+ @custom_pages = []
9
10
  end
10
11
 
11
12
  # Register a model for admin management
12
13
  # Usage: config.register "User", columns: %w[id email created_at]
13
14
  # Usage: config.register "User" (defaults to first 6 columns)
14
- def register(model_name, columns: nil)
15
+ # Usage: config.register "Product", has_many_through: [{ association: :desk_buddy_versions, join_model: "ProductVersion", foreign_key: :product_id, through_key: :desk_buddy_version_id }]
16
+ # Usage: config.register "Order", custom_actions: [{ name: :ship, label: "Mark Shipped", method: :patch, confirm: "Mark this order as shipped?" }]
17
+ def register(model_name, columns: nil, has_many_through: nil, custom_actions: nil)
15
18
  name = model_name.to_s.classify
16
- @models[name] = { columns: columns&.map(&:to_s) }
19
+ @models[name] = {
20
+ columns: columns&.map(&:to_s),
21
+ has_many_through: has_many_through || [],
22
+ custom_actions: custom_actions || []
23
+ }
17
24
  end
18
25
 
19
26
  def model_names
20
27
  @models.keys
21
28
  end
29
+
30
+ # Register a custom sidebar page that routes to the host app
31
+ # Usage: config.add_page "STL Models", path: "/admin/stl_models", icon: "📦"
32
+ def add_page(label, path:, icon: nil)
33
+ @custom_pages << { label: label, path: path, icon: icon }
34
+ end
22
35
  end
23
36
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AdminResources
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.4"
5
5
  end
@@ -11,6 +11,7 @@ module AdminResources
11
11
  end
12
12
 
13
13
  def configure
14
+ @configuration = Configuration.new
14
15
  yield configuration
15
16
  end
16
17
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: admin_resources
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - mark rosenberg
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-22 00:00:00.000000000 Z
11
+ date: 2026-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails