A store can be listed or not

Soft deletion already said "this store is gone". What was missing is "not yet" —
a catalog still being set up, or one pulled from the picker for a while without
losing the row and its history. `GET /api/stores` now answers with the active
ones only.

The client is deliberately told nothing about the flag. It has no state for
"there but switched off", and giving it one would mean every client release
having an opinion about it; an inactive store is simply absent, which is a case
the client already handles because it is the same as never having existed. The
payload stays two fields, and a spec holds it there.

Default true, so the migration lists every store that exists today. One that
silently emptied the registry would be a client with nothing to install from.
No index: a handful of rows, read once per client on first run.

In the admin the flag is what the page is *for*, so it is not just a checkbox on
the form: Active is the default scope, the index shows listed/hidden as a status
tag with a one-click toggle beside Edit, and the two batch actions do it in bulk.
A request spec covers all of it, because none of it is reachable from a model
spec — the pipelines resource shipped without `permit_params` and every edit
raised, which is the same layer and the same lesson. The last example toggles in
the admin and then reads /api/stores, since a change here that the registry does
not reflect is the only failure that actually matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:23:20 +02:00
co-authored by Claude Opus 5
parent 23f4c43da8
commit bcb95424c3
8 changed files with 184 additions and 6 deletions
+40 -3
View File
@@ -1,8 +1,14 @@
ActiveAdmin.register Store do
permit_params :name, :catalog_url
permit_params :name, :catalog_url, :active
menu priority: 5, label: "🛒 Stores"
# Active first and by default: the registry is what clients actually read, so the
# useful question on opening this page is "what is being offered right now".
scope("Active", default: true) { |scope| scope.active }
scope("Inactive") { |scope| scope.where(active: false) }
scope :all
index do
selectable_column
id_column
@@ -10,12 +16,38 @@ ActiveAdmin.register Store do
column :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
column :active do |store|
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
end
column :updated_at
actions
actions defaults: true do |store|
# One click, because this is the thing an admin comes to this page to do — and
# the alternative is Edit, a checkbox and Save for a single boolean.
link_to store.active? ? "Hide" : "List",
toggle_admin_store_path(store),
method: :put
end
end
filter :name
filter :catalog_url
filter :active
member_action :toggle, method: :put do
resource.update!(active: !resource.active?)
redirect_back fallback_location: admin_stores_path,
notice: "#{resource.name} is now #{resource.active? ? 'listed' : 'hidden'}"
end
batch_action :list do |ids|
Store.where(id: ids).update_all(active: true, updated_at: Time.current)
redirect_to admin_stores_path, notice: "#{ids.size} store(s) listed"
end
batch_action :hide do |ids|
Store.where(id: ids).update_all(active: false, updated_at: Time.current)
redirect_to admin_stores_path, notice: "#{ids.size} store(s) hidden"
end
show do
attributes_table do
@@ -24,6 +56,9 @@ ActiveAdmin.register Store do
row :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
row :active do |store|
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
end
row :created_at
row :updated_at
end
@@ -31,7 +66,8 @@ ActiveAdmin.register Store do
"Listed by GET /api/stores, which the graphical client reads on first run. A name " \
"and a catalog are the whole record: the client carries its own store engine " \
"and configures itself from this much, deriving the store's slug from the " \
"catalog host."
"catalog host. An inactive store is left out of that answer entirely — the " \
"client has no state for 'there but switched off'."
end
end
@@ -39,6 +75,7 @@ ActiveAdmin.register Store do
f.inputs do
f.input :name, hint: "What the client shows in its store picker"
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
f.input :active, hint: "Off keeps the record but leaves it out of GET /api/stores"
end
f.actions
end
+6 -1
View File
@@ -17,9 +17,14 @@ class Store < ApplicationRecord
default_scope { where(deleted_at: nil) }
# Two different "not listed". Soft deletion is "this store is gone"; `active` is
# "not right now" — a catalog still being set up, or one pulled from the picker for
# a while. The client cannot tell the difference and should not have to: it lists
# whatever /api/stores hands it.
scope :active, -> { where(active: true) }
scope :ordered, -> { order(:name) }
def self.ransackable_attributes(auth_object = nil)
%w[id name catalog_url created_at updated_at]
%w[id name catalog_url active created_at updated_at]
end
end
+4 -1
View File
@@ -1,5 +1,8 @@
class StoreService
# Only the active ones. An inactive store is simply absent from the registry — the
# client has no state for "there but switched off", and inventing one would mean
# every client release having an opinion about it.
def index
StoreSerializer.render_as_hash(Store.ordered)
StoreSerializer.render_as_hash(Store.active.ordered)
end
end