Midwest::CommandPaletteBuilder
Overview
Midwest::CommandPaletteBuilder is a lightweight builder for assembling command palette items with conditional gating. Instead of constructing arrays of hashes with inline conditionals and .compact, you declare items in a block — each with an optional permit: callable that controls whether the item appears.
The first argument to #item can be a string label, an ActiveModel class, or an ActiveModel instance. When a record is passed, the builder infers label:, url:, and icon: automatically — any explicit keyword overrides win.
Record inference is powered by Midwest::RecordInference, the same module used by BreadcrumbsComponent, so models behave consistently across the command palette and breadcrumbs.
The builder produces the same plain-hash format consumed by Midwest::TurboCommands, so it slots into an existing midwest_command_palette_items implementation with no changes to the component or routing layer.
Why use it
A typical command palette method starts clean but grows unwieldy as you add permission checks, dynamic items, and new groups:
# Before — conditional hashes, .compact, mixed concernsdef midwest_command_palette_items(query) policy = AccessPolicy.new(current_user) items = [ { label: "Checks", url: checks_path, group: "Pages", icon: :check }, ({ label: "Users", url: users_path, group: "Pages" } if policy.can?(:read, User)), ({ label: "Add Batch", url: new_batch_path, group: "Actions" } if policy.can?(:create, Batch)), { label: "Log out", url: logout_path, group: "Actions", method: :post }, ].compact # …dynamic items, filtering…endThe builder replaces this with a declarative block where each item reads as a single line, gating is data (not control flow), and model classes/instances carry their own label, URL, and icon.
Usage
Basic example
def midwest_command_palette_items(query) builder = Midwest::CommandPaletteBuilder.new do |p| p.item "Dashboard", url: root_path, group: "Pages", icon: :home p.item "Settings", url: settings_path, group: "Pages", icon: :settings p.item "Log out", url: logout_path, group: "Actions", icon: :"log-out", method: :post end return builder.items if query.blank? builder.items.select { |item| item[:label].downcase.include?(query.downcase) }endPassing a model class
Pass an ActiveModel class as the first argument and the builder infers:
label:— pluralized human name (e.g.Check→"Checks")url:— collection path viapolymorphic_path(e.g.Check→"/checks")icon:— class-level icon when the model includesMidwest::Iconable
builder = Midwest::CommandPaletteBuilder.new do |p| p.item Check, group: "Pages" p.item Batch, group: "Pages" p.item User, group: "Pages", permit: -> { policy.can?(:read, User) }endOverride any inferred value with an explicit keyword:
# Uses User's icon but routes to the admin namespacep.item User, url: admin_users_path, group: "Pages"# Custom label, inferred URL and iconp.item Batch, label: "All Batches", group: "Pages"Passing a model instance
Pass an ActiveModel instance and the builder infers:
label:— fromto_label,name,title, or the singular human model name (tried in that order)url:— member path viapolymorphic_path(e.g.batch→"/batches/42")icon:— instance-level icon when the model includesMidwest::Iconable
builder = Midwest::CommandPaletteBuilder.new do |p| Batch.recent.limit(3).each do |batch| p.item batch, group: "Recent Activity", description: "#{batch.checks.pending.size} pending checks" endendDefine to_label on your model for a richer default label:
class Batch < ApplicationRecord def to_label "Batch #{batch_identifier}" endend# Now batch instances get "Batch B-2024-001" as their label automaticallyp.item batch, group: "Recent Activity"Gating items with permit:
Pass any callable (proc, lambda, method object, or any object responding to #call) to permit:. When the callable returns a falsy value the item is silently excluded. When permit: is omitted the item is always included. Works identically with string labels and records.
def midwest_command_palette_items(query) policy = AccessPolicy.new(current_user) builder = Midwest::CommandPaletteBuilder.new do |p| p.item Check, group: "Pages" p.item User, url: admin_users_path, group: "Pages", permit: -> { policy.can?(:read, User) } p.item "Add New Batch", url: new_batch_path, group: "Actions", icon: Batch.icon, permit: -> { policy.can?(:create, Batch) } p.item "Log out", url: logout_path, group: "Actions", method: :post end return builder.items if query.blank? builder.items.select { |item| item[:label].downcase.include?(query.downcase) }endThe permit: key is stripped from the output — only the standard item keys reach CommandItemComponent.
Dynamic items
Add items inside loops within the block. The permit: gate works the same way:
builder = Midwest::CommandPaletteBuilder.new do |p| p.item Check, group: "Pages" Batch.recent.limit(3).each do |batch| p.item batch, group: "Recent Activity", description: "#{batch.checks.pending.size} pending checks", permit: -> { policy.can?(:read, Batch) } endendCustom callable objects
permit: accepts anything that responds to #call, not just procs. This is useful when gating logic is more complex or reusable:
class FeatureGate def initialize(flag) @flag = flag end def call Flipper.enabled?(@flag) endendbuilder = Midwest::CommandPaletteBuilder.new do |p| p.item "Beta Dashboard", url: beta_path, group: "Pages", permit: FeatureGate.new(:beta_dashboard) p.item "Export CSV", url: export_path, group: "Actions", permit: -> { current_user.admin? && FeatureGate.new(:csv_export).call }endAPI
CommandPaletteBuilder.new(&block)
Creates a new builder. The block receives the builder instance and is evaluated immediately.
builder = Midwest::CommandPaletteBuilder.new do |p| p.item "Home", url: "/"end#item(label_or_record, permit: nil, **options)
Registers a command palette item.
| Parameter | Type | Description |
|---|---|---|
label_or_record |
String, class, or instance |
Required. A string label, an ActiveModel class, or an ActiveModel instance |
permit: |
callable | Optional gating callable. Item is excluded when it returns falsy |
**options |
keywords | All remaining options are forwarded into the item hash |
Inference rules when a record is passed:
label: |
url: |
icon: |
|
|---|---|---|---|
| Class | model_name.human.pluralize |
polymorphic_path(klass) |
klass.icon if Iconable |
| Instance | to_label, name, title, or model_name.human |
polymorphic_path(record) |
record.icon if Iconable |
All inferred values can be overridden by passing the corresponding keyword explicitly.
Standard option keys (passed through to CommandItemComponent):
| Key | Description |
|---|---|
label: |
Override inferred label |
url: |
Override inferred URL, or set navigation URL for string items |
icon: |
Override inferred icon, or set icon for string items |
action: |
Custom event action name |
description: |
Secondary text below the label |
shortcut: |
Keyboard shortcut string (e.g. "⌘+N") |
group: |
Group heading for the item |
method: |
HTTP method (e.g. :post) |
confirm: |
Confirmation message before navigation |
#items
Returns the array of item hashes that passed their permit: checks. This is the value you return from midwest_command_palette_items.
Full example
class DashboardController < ApplicationController include Midwest::TurboCommands turbo_commands private def midwest_command_palette_items(query) policy = AccessPolicy.new(current_user) builder = Midwest::CommandPaletteBuilder.new do |p| p.item Check, group: "Pages" # infers label, url, icon p.item Batch, group: "Pages" # infers label, url, icon p.item "Users", url: admin_users_path, group: "Pages", icon: User.icon, permit: -> { policy.can?(:read, User) } # string label — namespaced route p.item "Add New Batch", url: new_batch_path, group: "Actions", icon: Batch.icon, permit: -> { policy.can?(:create, Batch) } p.item "Log out", url: logout_path, group: "Actions", icon: "log-out", method: :post recent_batches.each do |batch| p.item "View Batch (#{batch.batch_identifier})", url: batch_checks_path(batch), group: "Recent Activity", icon: batch.icon, description: batch_description(batch), permit: -> { policy.can?(:read, Batch) } end end return builder.items if query.blank? builder.items.select { |item| item[:label].downcase.include?(query.downcase) } end def recent_batches Batch.where(created_at: 1.week.ago..Date.today).order(created_at: :desc).limit(3) end def batch_description(batch) pending = batch.checks.pending.size matching = batch.matching? ? ", matching..." : "" "With #{pending} pending checks#{matching}" endend