| name | avo-forms-and-pages |
| description | Build model-agnostic forms and sidebar-navigable page hierarchies in an Avo admin — screens that aren't CRUD on a record. A form declares fields with the full Avo field DSL and handles its own submission in plain controller code; pages organize forms into a main page + sub-pages with their own sidebar. Use when the user wants a settings or preferences screen with no backing model, an app configuration / feature-flags UI, a data-import or upload-and-process form, a custom admin workflow form, a form that updates several models or kicks off a background job on submit, a multi-section settings area with a sidebar, or a standalone form to drop into any view — whether they say it in Avo terms ("Avo::Forms::Core::Form", "def handle", "def navigation", "def content", "avo:form / avo:page generator", "avo-forms add-on") or as a plain product ask ("a settings page that isn't tied to a record", "a preferences screen", "an admin import form", "a page that saves config"). |
| allowed-tools | Read, Edit, Write, Glob, Grep, Bash, WebFetch |
| metadata | {"requires-gem":"avo-forms — paid add-on, Beta (https://avohq.io/addons/forms)"} |
Avo Forms & Pages
Build standalone forms and page hierarchies in an Avo admin — screens that aren't CRUD on a database record. A form is a class under app/avo/forms/ that declares its fields (with the full Avo field DSL) and handles its own submission. A page under app/avo/pages/ organizes forms into a sidebar-navigable interface: a main page holds the navigation, sub-pages hold the content.
Reach for this for application settings, user preferences, feature-flag toggles, data imports, or any custom admin workflow that ends in "do something" rather than "save this record."
Two things to fix before writing any code:
- Form, page, or both? A form is one screen of fields + a submit handler. A page is the sidebar container that hosts one or more forms (and sub-pages). A single form linked from the menu needs no page at all — see Render a form standalone. A settings area with multiple sections needs a main page + sub-pages.
- This is a paid, Beta add-on.
avo-forms is a licensed add-on (not Community) and currently Beta — the handle controller contract is flagged experimental and may change. Requires Avo >= 4.0 with the add-on enabled on the license.
Docs — fetch on demand with WebFetch; prefer the raw .md (clean, no HTML). Read the page before implementing anything non-trivial.
Install
avo-forms self-registers — there is no initializer to edit. Add the gem and bundle:
gem "avo-forms", source: "https://packager.dev/avo-hq/"
bundle install
The engine hooks Avo's avo_boot and registers itself via Avo.plugin_manager (it also contributes the page / all_pages / form / all_forms menu DSL). Then scaffold with the generators:
bin/rails generate avo:form general_settings
bin/rails generate avo:page settings
Requirements: Avo >= 4.0, an active license with the forms add-on enabled, and it's Beta.
When this applies
| Request (Avo-shaped or plain product) | Build |
|---|
| "A settings / preferences page with no model", "app configuration screen", "feature-flags UI" | A form hosted on a page |
| "A form that isn't tied to a record", "a one-off admin form" | A single form, linked via .component or a menu form entry |
| "A data import / upload-and-process form", "kick off a background job from an admin form" | A form whose handle does the work |
| "Update several models on submit", "a custom admin workflow" | A form — handle is plain controller code |
| "A multi-section settings area with a sidebar", "grouped config screens" | A main page with sub-pages |
| "Drop a form into one of my own views" | Form.component |
Related skills: the field types/options inside fields are avo-fields (and panels/cards layout); adding pages/forms to the main menu is avo-navigation-search; rendering a form's .component inside custom markup is avo-custom-ui.
Workflow
- Decide the shape — one form, or a page hosting several. Keep the hierarchy shallow (a main page + one level of sub-pages covers almost everything).
- Generate the form(s) with
bin/rails generate avo:form <name>; fill in fields and handle.
- Generate the page with
bin/rails generate avo:page <name> if you need a container; declare navigation (main page) and/or content (sub-page).
- Wire children — reference sub-pages/forms from the main page's
navigation; list forms in each sub-page's content.
- Add it to the menu — pages don't appear until you add them (menu section).
- Verify — boot the app, open the page, submit the form, confirm the flash and side effects.
Forms
A form inherits from Avo::Forms::Core::Form. It needs only two methods: fields (what to show) and handle (what to do on submit). title, description, and routing all have sensible defaults.
class Avo::Forms::AppSettings < Avo::Forms::Core::Form
self.title = "Application Settings"
self.description = "Manage your application configuration"
def fields
field :app_name, as: :text
field :maintenance_mode, as: :boolean
end
def handle
flash[:notice] = "Settings updated successfully"
default_response
end
end
Build fields
Declare fields inside fields with the same field DSL as resources and actions — every Avo field type and option works (required:, default:, help_text:, placeholder:, width:, record:, …). See avo-fields for the full catalog.
def fields
field :email, as: :text, required: true
field :notifications, as: :boolean, default: true
field :theme, as: :select, options: { light: "Light", dark: "Dark" }
end
Group with cards and panels. Wrap related fields in card or panel; both take title: and description::
def fields
card title: "Personal Information" do
field :first_name, as: :text
field :last_name, as: :text
end
panel title: "Feature Flags", description: "Toggle features" do
field :enable_registrations, as: :boolean, default: true
field :max_upload_size, as: :number, default: 10, help_text: "In MB"
end
end
Lay fields side by side with width:. with_options applies one option to a whole group:
def fields
card do
with_options width: 50 do
field :first_name, as: :text
field :last_name, as: :text
end
end
end
Prefill from a record by binding a field (or a with_options group) to it via record:. A form has no backing record of its own, so this is how you show current values:
def fields
with_options record: Avo::Current.user do
field :first_name, as: :text
field :last_name, as: :text
field :email, as: :text
end
end
Handle submission
handle runs in the controller's context — the submitted data is in params, and every controller helper (current_user, flash, cookies, redirect_to, params.permit, path helpers) is available directly. It's plain Rails: update several models, branch on input, enqueue a job. Finish by calling default_response for the standard redirect-back Turbo Stream response (or issue your own redirect_to).
def handle
current_user.update(params.permit(:first_name, :last_name, :email))
flash[:notice] = "Profile updated successfully"
default_response
end
def handle
ImportJob.perform_later(params[:file]) if params[:import_data].present?
Post.create(title: params[:title], body: params[:body])
default_response
end
Flash messages — set flash[:notice] / :error / :success / :warning before returning. A String is a simple message; a Hash controls the timeout (a millisecond number, or :forever to keep it until dismissed):
flash[:notice] = "Operation completed"
flash[:success] = { body: "Saved", timeout: 3000 }
flash[:warning] = { body: "Heads up", timeout: :forever }
Reusable vs. inline forms
The generator gives each form its own file — the default, and the right choice for anything reused across pages or rendered as a component. For a form that belongs to exactly one page, you can nest it inline in the page class, but a nested Avo::Pages::Settings::Integrations::ApiConfiguration doesn't read as a form. Give it its own file the moment you reuse it.
Render a form standalone
A form can be dropped into any view as a component — no page required:
<%# app/views/some/view.html.erb %>
<%= render Avo::Forms::AppSettings.component %>
To surface a single form in the menu without a page, use the form menu DSL (see menu section).
Pages
A page inherits from Avo::Forms::Core::Page. Hierarchy is derived from namespace depth:
- Main page — one level under
Avo::Pages (e.g. Avo::Pages::Settings). A container that gets a sidebar menu entry and defines navigation.
- Sub-page — nested deeper (e.g.
Avo::Pages::Settings::General). Holds the actual forms via content, reached through the parent's navigation.
When a user opens a main page, Avo redirects to its default: sub-page if one is marked; otherwise it renders the main page's own content. Sub-pages appear in a sidebar for switching.
Navigation (main page). Register children inside navigation with page and form. Mix all three styles freely:
class Avo::Pages::Settings < Avo::Forms::Core::Page
self.title = "Settings"
self.description = "Manage your application settings"
def navigation
page Avo::Pages::Settings::General, default: true
page Avo::Pages::Settings::Security
form Avo::Forms::UserProfiles
page "Integrations",
description: "Connect third-party services",
content: -> do
form Avo::Forms::Settings::Slack
form Avo::Forms::Settings:
Content (sub-page). List the forms with form; they render in declaration order, each with its own header. Drop a form's header for a given placement with show_header: false:
class Avo::Pages::Settings::General < Avo::Forms::Core::Page
self.title = "General Settings"
def content
form Avo::Forms::AppSettings
form Avo::Forms::CompanyInfo, show_header: false
end
end
Menu label. A page's menu label defaults to its title. Set self.navigation_label for a shorter/different label in the menu than the on-page title.
Add to the menu
Pages don't appear in Avo's navigation until you add them, in config.main_menu. Reference a page by class string (a String avoids autoloading it at parse time), or pull them all in with all_pages. form / all_forms link forms directly:
Avo.configure do |config|
config.main_menu = -> {
section "Configuration", icon: "tabler/outline/settings" do
page "Avo::Pages::Settings"
end
}
end
Adding menu icons/structure is avo-navigation-search territory.
Gotchas
- Beta add-on, paid. Not Community — needs the forms add-on enabled on an Avo >= 4.0 license. It self-registers, so there is no initializer to change; if it's not loading, check the Gemfile source and the license, not
config.
handle is EXPERIMENTAL. It runs in raw controller context by design (full Rails toolbox), but the contract may change in a future release. Don't build brittle assumptions on internals beyond the documented helpers (params, current_user, flash, cookies, redirect_to, default_response).
navigation and content are parsed ONCE at boot. They're evaluated a single time during application boot, not per request. Keep them static — no conditionals, no dynamic/user-dependent logic, no per-request branching. Dynamic behavior belongs in handle or in the field default:/visibility options.
- Always validate and permit params in
handle before acting — it's an open controller action. Use params.permit(...); never mass-assign raw params.
- Pages are invisible until added to the menu. Generating a page doesn't surface it — add it via
page/all_pages in config.main_menu.
- A main page redirects to its
default: sub-page. If a main page is mostly a container, mark one child default: true so it lands somewhere useful; without a default it renders the main page's own (often empty) content.
id must be unique across all forms, and across all pages. Routes resolve by id at request time (no per-class routes). id defaults to the class path (Avo::Pages::Settings::General → settings/general); override with self.id. A collision means one screen shadows another.
- Give a reused form its own file. Inline (nested-in-page) forms are fine for a single, page-specific form, but a deeply nested form class doesn't read as a form and can't be cleanly reused — extract it to
app/avo/forms/ the moment a second page wants it.
- Forms have no backing record and behave like a "new" view — this is why values fill in and why prefilling needs .
Report
When done, tell the user:
- What you built — each form (with its
fields and what handle does: which models it writes, jobs it enqueues, params it permits) and each page (main vs. sub, its navigation/content), with absolute file paths.
- How it's reached — the sub-page hierarchy, any
default: sub-page, and the exact config.main_menu entry you added (or that the user still needs to add).
- Any standalone
.component render sites.
- Follow-ups they own: enabling the add-on on the license, permitting/validating params in
handle, backing models responding to the attributes, and that navigation/content must stay static (boot-time) — plus the Beta caveat on handle.