admin_resources 0.2.2 → 0.2.3

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: c4fb7ecf52ff124948a11973d5b63b3cbe3737158da8fec91073798240e0590b
4
+ data.tar.gz: af1ff3a8d57925be7120991d2a9fc1b79562c17fd93f02bac6800dbdcc95defb
5
5
  SHA512:
6
- metadata.gz: 3cea885b9b6c4c774eb3924c8f8ead907a62ad8004d45ac477cc1d27bc0184affe7a7573347ac568d180114088ed2b8b2f12b232e890449f3472a2f93a2dd7a3
7
- data.tar.gz: 680888318eaeda7c0ab8ba92654f7fbbee1a7a1ba37195f134607a42d9462196bc2e09830203e022548c71064ff8816831dd6abfc3fb64e6fe81a904e1ca200e
6
+ metadata.gz: 1296c290116af677ce5d57988eddf05938d18180065a4b2a3652853193e644bf056d78a5d97bdd60ec7714abe3f1392d3c788349c65a4f883d4aa55a8b76d005
7
+ data.tar.gz: 48558c73eb234283b8bcfc5c5e7d621b765c4d746c9d1799392b055c53427cf7ba8b43b90884f7da66f1326ba29abafd137fc476ec9089843f870f9e259d301b
@@ -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
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
@@ -92,17 +122,14 @@ module AdminResources
92
122
  assoc_name = column.sub(/_id$/, "")
93
123
  association = resource.class.reflect_on_association(assoc_name.to_sym)
94
124
 
95
- if association && association.macro == :belongs_to
125
+ if association && association.macro == :belongs_to && !association.options[:polymorphic]
96
126
  assoc_class = association.klass
97
127
  assoc_model = assoc_class.name
98
128
 
99
129
  if AdminResources.model_names.include?(assoc_model)
100
130
  associated_record = assoc_class.find_by(id: value)
101
131
  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?
132
+ display = associated_record.to_s
106
133
  return [display, admin_path_for(assoc_model, :show, associated_record)]
107
134
  end
108
135
  end
@@ -123,5 +150,74 @@ module AdminResources
123
150
  end
124
151
  params.require(model_class.model_name.param_key).permit(*permitted)
125
152
  end
153
+
154
+ def join_associations
155
+ AdminResources.models[model_name]&.dig(:has_many_through) || []
156
+ end
157
+
158
+ def custom_actions
159
+ AdminResources.models[model_name]&.dig(:custom_actions) || []
160
+ end
161
+
162
+ # Returns only the declared index columns that actually exist as DB columns (excludes virtual/through cols)
163
+ def filter_columns
164
+ index_columns.select { |col| model_class.columns_hash[col] }
165
+ end
166
+
167
+ def filter_resources(scope)
168
+ return scope unless params[:q].is_a?(Hash)
169
+
170
+ params[:q].each do |col, value|
171
+ next if value.blank?
172
+ next unless filter_columns.include?(col)
173
+
174
+ column = model_class.columns_hash[col]
175
+ next unless column
176
+
177
+ case column.type
178
+ when :string, :text, :citext
179
+ scope = scope.where("#{model_class.quoted_table_name}.#{connection.quote_column_name(col)} ILIKE ?", "%#{value}%")
180
+ when :integer, :bigint
181
+ scope = scope.where(col => value.to_i) if value.match?(/\A-?\d+\z/)
182
+ when :boolean
183
+ scope = scope.where(col => ActiveModel::Type::Boolean.new.cast(value)) unless value == "any"
184
+ when :date, :datetime
185
+ scope = scope.where(col => value) if value.present?
186
+ else
187
+ scope = scope.where(col => value)
188
+ end
189
+ end
190
+
191
+ scope
192
+ end
193
+
194
+ def connection
195
+ model_class.connection
196
+ end
197
+
198
+ def sync_join_associations
199
+ join_associations.each do |jdef|
200
+ join_model_class = jdef[:join_model].safe_constantize
201
+ next unless join_model_class
202
+
203
+ foreign_key = jdef[:foreign_key]
204
+ through_key = jdef[:through_key]
205
+ param_key = "#{jdef[:association]}_ids"
206
+ submitted_ids = (params[model_class.model_name.param_key] || {})[param_key]
207
+
208
+ next if submitted_ids.nil?
209
+
210
+ new_ids = Array(submitted_ids).map(&:to_i).reject(&:zero?)
211
+
212
+ existing = join_model_class.where(foreign_key => @resource.id)
213
+ existing_ids = existing.pluck(through_key)
214
+
215
+ to_add = new_ids - existing_ids
216
+ to_remove = existing_ids - new_ids
217
+
218
+ join_model_class.where(foreign_key => @resource.id, through_key => to_remove).destroy_all if to_remove.any?
219
+ to_add.each { |tid| join_model_class.create!(foreign_key => @resource.id, through_key => tid) }
220
+ end
221
+ end
126
222
  end
127
223
  end
@@ -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,22 @@
47
59
  </div>
48
60
  <% end %>
49
61
 
62
+ <% join_associations.each do |jdef| %>
63
+ <% assoc_name = jdef[:association] %>
64
+ <% param_key = "#{assoc_name}_ids" %>
65
+ <% target_class = assoc_name.to_s.classify.safe_constantize %>
66
+ <% if target_class && target_class < ActiveRecord::Base %>
67
+ <div class="field">
68
+ <%= form.label param_key, assoc_name.to_s.humanize %>
69
+ <% current_ids = @resource.persisted? ? @resource.send(assoc_name).pluck(:id) : [] %>
70
+ <%= form.collection_select param_key, target_class.all, :id, :to_s,
71
+ { selected: current_ids, include_blank: false },
72
+ { multiple: true, size: 6 } %>
73
+ <small>Hold Ctrl / Cmd to select multiple</small>
74
+ </div>
75
+ <% end %>
76
+ <% end %>
77
+
50
78
  <div class="field">
51
79
  <%= form.submit class: "admin-btn admin-btn-primary" %>
52
80
  <%= 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.3"
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.3
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-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails