# Avo for Ruby on Rails Documentation - Version 4.0 Generated from Avo documentation v4.0 for LLM consumption. Concise index: https://docs.avohq.io/4.0/llms.txt Documentation map (every page with its headings): https://docs.avohq.io/4.0/docs-map.md # Getting started Avo turns your Rails app into something your whole team can operate. You describe your data in small Ruby configuration files, and Avo builds the interface around it: the screens to browse and edit records, the actions that run your business logic, the dashboards, the search. It's the layer your team logs into every day to run the product, whether you use it as an admin panel, an internal tool, or the app itself. Everything in these docs fits into three rings: 1. **[The core](#the-core)**. Describe your data once and Avo generates the full interface around it. 2. **[Add-ons](#add-ons)**. Optional gems that extend the core with new capabilities as your needs grow. 3. **[Your code](#your-code)**. Escape hatches at every level, so configuration never boxes you in. ## The core You don't build screens with Avo. You tell it what data you have, and it builds the screens. A resource maps to one of your models and declares its fields: `first_name` as `text`, `birthday` as `date`, `cover_photo` as `file`, and so on. From that, Avo renders the views to list, show, create, and edit records, complete with associations, validations surfaced from your models, and file uploads through Active Storage. ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields field :id, as: :id field :first_name, as: :text field :birthday, as: :date field :cover_photo, as: :file field :projects, as: :has_many end end ``` Around resources sit the tools your team works with every day: actions to run business logic on one or many records, filters to slice lists, and search to get to any record fast. ## Add-ons The core covers managing records. Add-ons extend it toward whatever job your app or your internal tools have to do. Each one is a separate gem you install when the need shows up, and it slots into the same configuration style as everything else. **Find and organize records** - Global Search: search every resource from one box. - Dynamic Filters: let users stack their own filter combinations without you writing each one. - Resource Scopes: reuse the model scopes you already trust as one-click segments. - Searchable Associations: type-to-search when attaching related records, even in huge tables. - Record Reordering: drag-and-drop ordering for lists where position matters. - Menu Editor: shape the sidebar into a navigation that makes sense for your team. **Capture and edit data** - Forms: standalone forms and pages beyond your resources, for surveys, settings, and bespoke flows. - Nested Records: create and edit associated records inside the parent form. - Reactive Fields: fields that show, hide, or update based on what the user picks. - Media Library: manage every uploaded asset in one place. **Run day-to-day operations** - Dashboards and cards: metrics and charts at a glance. - Kanban: drag-and-drop boards for pipelines and workflows. - Notifications: in-app notifications for the events that matter. - Collaboration: comments and status updates on records, so context stays with the data. - Audit Logging: who did what, and when. **Control who can do what** - Authorization: granular permissions using Pundit policies. - Custom Controls: decide exactly which buttons and actions each user sees. **Connect other systems** - JSON API: expose your resources as a JSON API for integrations and mobile apps. - HTTP Resource: manage data from an external API as if it were local. ## Your code When configuration isn't enough, you drop down to plain Rails. Avo is built to be extended, not worked around: - Custom fields slot into panels and views just like built-in ones. - Resource tools embed your own partials inside a resource's pages. - Custom tools give you entire pages built from partials or View Components. - Eject views copies any of Avo's own templates into your app for full control. - JavaScript & Stimulus is baked in for sprinkling interactivity anywhere. - Plugins package your extensions for reuse across apps. ## Security Avo runs entirely inside your Rails app. It ships as a gem, reads and writes through your own models, and your data stays on your servers; there's no external dashboard or third-party service between your team and your database. On top of that, you control access at every level: authentication plugs into whatever you already use (Devise, the Rails 8 scaffold, or anything that gives you a current user), authorization enforces granular permissions through Pundit policies, Custom Controls decide which buttons each user even sees, and Audit Logging keeps a record of who did what. ## Build it with AI agents The same thing that makes Avo fast for you makes it fast for AI agents: features are small, declarative Ruby files, so agents like Claude Code or Cursor generate resources, actions, and filters that work on the first try instead of hallucinating view code. We lean into it. Point your agent at the [docs map](https://docs.avohq.io/4.0/docs-map.md) so it pulls accurate, current docs, and install the official [Avo skills](https://github.com/avo-hq/skills), pre-built workflows for building resources, writing tests, creating field types, and more. The Agentic engineering page walks through the full setup for every editor. ## Seamless upgrades Avo ships as a [gem](https://rubygems.org/gems/avo), so none of its internals live in your app: no file conflicts, no generated code drifting out of date. ```bash bin/rails avo:update ``` That resolves every Avo gem you have β€” core plus any add-ons β€” and updates them together, conservatively. ## Start here 1. Install Avo in your app 2. Hook up the current user 3. Create your first resource 4. Set up authorization 5. [Explore the live demo app](https://main.avodemo.com/) ## Where to go next - Best practices for structuring a real-world Avo app. - Guides with worked examples and recipes. - FAQ for common setup questions. - Avo 3 to Avo 4 upgrade if you're coming from an existing app. - Technical support when you get stuck. --- # Technical support Avo is designed to be a self-serve product with [comprehensive documentation](https://docs.avohq.io) and [demo apps](#demo-apps) to be used as references. But, even the best of us get stuck at some point and you might need a nudge in the right direction. There are a few levels of how can get help. 1. [Open Source Software Support Policy](#open-source-software-support-policy) 1. [Self-help](#self-help) 1. [Help from the official team](#official-support) ## Open Source Software Support Avo's Open Source Software (OSS) support primarily revolves around assisting users with issues related to the Avo and other Avo libraries. This involves troubleshooting and providing solutions for problems originating from Avo or its related subcomponents. However, it is crucial to understand that the OSS support does not extend to application-specific issues that do not originate from Avo or its related parts. This includes but is not limited to: - Incorrect application configurations unrelated to Avo. - Conflicts with other libraries or frameworks within your application. - Deployment issues on specific infrastructure or platforms. - Application-specific runtime errors. - Problems caused by third-party plugins or extensions. - Data issues within your application. - Issues related to application performance optimization. - Integration problems with other services or databases. - Design and architecture questions about your specific application. - Language-specific issues are unrelated to Avo or other Avo libraries. We acknowledge that understanding your specific applications and their configuration is essential, but due to the time and resource demands, this goes beyond the scope of our OSS support. :::tip Enhanced support For users seeking assistance with application-specific issues, we offer a few paid technical support plans. These subscriptions provide comprehensive support, including help with application-specific problems. 1. Priority chat support 2. Advanced hands-on support For more information about our support plans, please visit [this](https://avohq.io/support) page. ::: ## Self help This is how you can help yourself. ## Help from the official team You sometimes need help from the authors. There are a few ways to do that. ## Reproduction repository The easiest way for us to troubleshoot and check on an issue is to send us a reproduction repository which we can install and run in our local environments. ```bash # run this command to get a new Rails app with Avo installed rails new -m https://avo.cool/new.rb APP_NAME # run to install avo-pro rails new -m https://avo.cool/new-pro.rb APP_NAME # run to install avo-advanced rails new -m https://avo.cool/new-advanced.rb APP_NAME ``` --- # Agentic engineering Avo is designed to work well with AI coding agents. This page covers the tools and setup that let you build Avo features faster by pairing with agents like Claude Code, Cursor, Windsurf, and others. ## Code editors and LLM setup AI agents generate better code when they have up-to-date Avo documentation in their context. Point your tool at https://docs.avohq.io/4.0/docs-map.md β€” a map of every Avo 4 docs page and its headings, with links β€” and the agent will fetch exactly the pages it needs to generate accurate resources, fields, actions, filters, and more. ### Pick your tool ## Skills Skills are pre-built instruction sets that teach your agent how to perform specific Avo workflows. Instead of prompting from scratch each time, you install a skill and the agent follows a proven, repeatable process. The skills ship **inside the `avo` gem**, so they describe the version your app has locked rather than whatever version a globally-installed copy happened to be written for. Install the loader that finds them with **`rails g avo:skills`**: That starts a short interactive installer β€” one question per screen, all answered up front, with a summary of exactly what it will do before it writes anything (Avo 4.1.3 and later). It: - installs into this app, or into your home directory so one copy serves every Avo project - sets up any of `.agents/skills`, `.claude/skills`, and `.cursor/skills` - writes one real file and symlinks the others to it, so they cannot drift apart - offers to remove skills left behind by an older, unversioned install It writes one small markdown file. The skills themselves are never copied into your repo β€” the loader resolves them from the installed gem when a task actually needs them. Skills are organized by **vertical** β€” a whole feature area, not a single task β€” so one skill covers creating, configuring, and troubleshooting that part of Avo. ### Keeping them current They are always current. The skills come from the gem your app has locked, so there is nothing to copy and nothing that can fall behind. Update your Avo gems with `bin/rails avo:update` and the skills come with them. Use that task rather than `bundle update avo`, which moves core alone and leaves each add-on's skills on the version they were already pinned to. ### Core | Skill | What it covers | | ------------------------- | --------------------------------------------------------------------------------------------------------------- | | `avo-resources` | generate and configure resources β€” title, includes, sorting, pagination, cover/avatar, array (non-DB) resources | | `avo-fields` | add and configure fields in `def fields` β€” pick the `as:` type, options, formatting, layout | | `avo-associations` | wire `belongs_to` / `has_many` / `has_one` / HABTM fields, polymorphism, STI, nested create-in-form | | `avo-actions` | build actions that run Ruby on selected, single, or no records β€” bulk ops, forms, modals, responses | | `avo-filters` | basic filters on the index β€” dynamic filters and scopes ship as their own add-on skills | | `avo-index-views` | control how the index renders β€” table styling, grid cards, map markers, view types | | `avo-custom-fields` | build a brand-new field type β€” generator plus its Edit/Show/Index view components | | `avo-menu-icons` | pick semantically appropriate Tabler icons and set them on resources and dashboards | ### Config & ops | Skill | What it covers | | ------------------------- | ---------------------------------------------------------------------------------------------- | | `avo-setup` | install Avo, mount it, authenticate the private gem server, and set the license key | | `avo-update` | bump the Avo gems and apply every upgrade-guide step for the versions crossed, with a log | | `avo-authentication` | tell Avo who the current user is, gate access, and wire roles / profile / sign-out | | `avo-admin-config` | global initializer knobs β€” app name, per-page, container width, density, home path | | `avo-performance` | caching and stale-row fixes to make the admin fast | | `avo-testing` | unblock the license check in the test suite and use Avo's test helpers | | `avo-multitenancy` | scope the admin per tenant β€” route- or session-based, with an account switcher | ### Customization | Skill | What it covers | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `avo-branding-appearance` | make the admin look like the product β€” logo, favicon, color scheme, palettes, CSS re-skin, icons | | `avo-navigation-search` | per-resource search, breadcrumbs, keyboard shortcuts, and the auto-generated sidebar | | `avo-custom-ui` | build custom pages, embedded panels, dynamic/nested forms, eject views, JS/Stimulus, Tailwind | | `avo-i18n` | translate and localize the admin β€” labels, locale switching, RTL | | `avo-media-library` | turn on the central asset browser and the picker inside rich-text editors | | `avo-controllers` | override per-resource CRUD controller hooks and safely extend Avo's `ApplicationController` | | `avo-engine-internals` | engine plumbing for custom Ruby β€” `main_app`/`avo` helpers, `Avo::Current`, `ExecutionContext`, reserved names | ### Add-ons Separately-licensed gems (paid add-on or Enterprise). These skills ship inside their own gem, not inside `avo`. | Skill | What it covers | | ------------------------- | -------------------------------------------------------------------------------------------------- | | `avo-dashboards-cards` | dashboards (grids of cards) and the six card types β€” metrics, charts, tables, lists | | `avo-notifications` | in-app notifications β€” bell dropdown, levels, action buttons, optional realtime | | `avo-rest-api` | JSON REST API over every resource, with token auth and a per-token permission matrix | | `avo-forms-and-pages` | model-agnostic forms (settings, imports, workflows) and sidebar page hierarchies | | `avo-kanban` | DB-backed drag-and-drop boards across resources | | `avo-audit-logging` | track who changed and viewed what β€” timeline, diffs, revert | | `avo-collaboration` | comments, reactions, and an automatic change-log on a record | | `avo-authorization` | restrict who sees and does what with Pundit policies β€” resources, actions, associations, files | | `avo-http-resource` | back a resource with an external HTTP API instead of Active Record | | `avo-menu` | build the sidebar with the menu editor DSL β€” sections, dividers, links, icons, per-user visibility | | `avo-scopes` | one-click segment tabs above the index, with counts and a default view | | `avo-dynamic-filters` | let end users build their own ad-hoc filters from a filters bar | | `avo-advanced-search` | the Cmd + K global search palette, and type-to-search association pickers | | `avo-record-reordering` | persistent up/down and drag-and-drop record ordering | | `avo-custom-controls` | take over the show/edit/index/row button bars β€” relabel, remove, add links/actions/dropdowns | ### Cross-cutting | Skill | What it covers | | ------------------------- | ---------------------------------------------------------------------------------------------- | | `avo-aware` | keep the admin in sync when you change a Rails model, even when the request never mentions Avo | | `avo-troubleshoot` | diagnose a broken or misbehaving Avo app, organized by symptom | Paid add-on skills arrive with their gems β€” install `avo-kanban` and its skill comes with it. The loader lists what your app actually has, and names the add-on for anything it does not, so an agent never describes a feature you cannot use. ### One loader for every project Choose **This machine** when the installer asks where the loader should go. The loader carries no version knowledge β€” it finds the app by walking up from the working directory and reads that app's `Gemfile.lock` β€” so a single copy serves every Avo project on the machine, each resolving its own version. Install once and you are done. The per-app install is still worth it for a team: the loader is one small file you commit, so everyone gets it on `git pull` rather than each person installing it themselves. ## MCP server For agents that support MCP, the [Context7](https://context7.com/) [MCP server](https://github.com/upstash/context7-mcp) serves up-to-date docs for many libraries, including Avo. The agent queries it for the exact docs it needs while building features, instead of relying on stale training data. For example, to add it to Claude Code, run: Each editor's [setup page](#pick-your-tool) covers how to add it to that tool. Then write `use context7` at the end of your prompt. ## Suggested workflow 1. Set up your editor with the Avo LLM context β€” [see above](#code-editors-and-llm-setup). 2. Install the Avo skills loader β€” run `rails g avo:skills` in your app. 3. Describe what you want to build. The agent will follow the skill workflow and reference the docs automatically. 4. Optionally connect the [Context7 MCP server](#mcp-server) so the agent can query Avo's docs directly. --- # Installation ## Requirements - Ruby on Rails >= 6.1 - Ruby >= 3.2 - `api_only` set to `false`. More here. - `propshaft` or `sprockets` gem - Have the `secret_key_base` defined in any of the following `ENV["SECRET_KEY_BASE"]`, `Rails.application.credentials.secret_key_base`, or `Rails.application.secrets.secret_key_base` :::warning Zeitwerk autoloading is required. When adding Avo to a Rails app that was previously a Rails 5 app you must ensure that it uses zeitwerk for autoloading and Rails 6.1 or higher defaults. ```ruby # config/application.rb config.autoloader = :zeitwerk config.load_defaults 6.1 # 6.1 or higher, depending on your rails version ``` ::: ## Installing Avo ### 1. One-command install Use [this](https://railsbytes.com/public/templates/zyvsME) app template for a one-liner install process. Run this command which will run all the required steps to install Avo in your app. ``` bin/rails app:template LOCATION='https://avohq.io/app-template' ``` :::tip Let an LLM do it for you Working with an AI coding assistant (Claude, Cursor, Copilot, etc.)? Paste this prompt and let it handle the install: ``` Install and configure Avo in this Rails app by following https://docs.avohq.io/4.0/installation.html β€” run the install steps, wire up the initializer and routes, then generate my first resource. ``` ::: ### 2. Manual, step by step. 1. Add the appropriate Avo gem to the `Gemfile` ```ruby # Avo Community gem "avo", ">= 4.0.0" # If you're on a paid plan, add the feature gems included in your license. source "https://packager.dev/avo-hq/" do # all or some of these gem "avo-authorization", ">= 4.0.0" gem "avo-advanced_search", ">= 4.0.0" gem "avo-advanced_file_uploads", ">= 4.0.0" gem "avo-record_reordering", ">= 4.0.0" gem "avo-menu_editor", ">= 4.0.0" gem "avo-menu", ">= 4.0.0" gem "avo-dashboards", ">= 4.0.0" gem "avo-http_resource", ">= 4.0.0" gem "avo-dynamic_filters", ">= 4.0.0" gem "avo-nested", ">= 4.0.0" gem "avo-collaboration", ">= 4.0.0" gem "avo-forms", ">= 4.0.0" gem "avo-kanban", ">= 4.0.0" gem "avo-api", ">= 4.0.0" gem "avo-reactive_fields", ">= 4.0.0" gem "avo-notifications", ">= 4.0.0" end ``` :::info Please use this guide to find the best authentication strategy for your use-case. ::: 2. Run `bundle install`. 3. Run `bin/rails generate avo:install` to generate the initializer and add Avo to the `routes.rb` file. 4. Generate an Avo Resource :::info This will mount the app under `/avo` path. Visit the link to see the result. ::: ### 3. In popular Rails starter kits We have integrations with the most popular starter kits. #### Bullet Train Avo comes pre-installed in all new Bullet Train applications. If you have a Bullet Train app and you'd like to add Avo, please use [this template](https://avohq.io/templates/bullet-train). ```ruby bin/rails app:template LOCATION=https://v3.avohq.io/templates/bullet-train.template ``` #### Jumpstart Pro To install Avo in a Jumpstart Pro app use [this template](https://avohq.io/templates/jumpstart-pro). ```ruby bin/rails app:template LOCATION=https://v3.avohq.io/templates/jumpstart-pro.template ``` ## Install from GitHub You may also install Avo from GitHub but when you do that you must compile the assets yourself. You do that using the `rake avo:build-assets` command. When pushing to production, make sure you build the assets on deploy time using this task. ```ruby # Rakefile Rake::Task["assets:precompile"].enhance do Rake::Task["avo:build-assets"].execute end ``` :::info If you don't have the `assets:precompile` step in your deployment process, please adjust that with a different step you might have like `db:migrate`. ::: ## Mount Avo to a subdomain You can use the regular `host` constraint in the `routes.rb` file. ```ruby constraint host: 'avo' do mount_avo at: '/' end ``` ## Next steps Please follow the next steps to ensure your app is secured and you have access to all the features you need. 1. Set up authentication and tell Avo who is your `current_user`. This step is required for the authorization feature to work. 1. Set up authorization. Don't let your data be exposed. Give users access to the data they need to see. 1. Set up licensing. --- # Routing We stick to Rails defaults in terms of routing just to make working with Avo as straightforward as possible. Avo's functionality is distributed across multiple gems, each encapsulating its own engine. By default, these engines are mounted under Avo's scope within your Rails application. Each engine registers itself with Avo, so you mount everything with a single `mount_avo` call. ## Default mounting behavior When the `mount_avo` method is invoked, Avo and all the associated engines are mounted at a common entry point. By default, this mounting point corresponds to `root_path`, but you can customize it using the `at` argument: ```ruby{4,7} # config/routes.rb Rails.application.routes.draw do # Mounts Avo at Avo.configuration.root_path mount_avo # Mounts Avo at `/custom_path` instead of the default mount_avo at: "custom_path" end ``` If no custom path is specified, Avo is mounted at the default configuration root path. `mount_avo` also accepts a block, so you can append your own routes to the Avo engine inline (see [Add your own routes](#add-your-own-routes)), and a `mount_lookbook: true` argument to mount [Lookbook](https://lookbook.build) alongside Avo in development. ## Mount Avo under a scope In this example, we'll demonstrate how to add a `:locale` scope to your routes. The `:locale` scope is just an example. If your objective is to implement a route scope for localization within Avo, there's a detailed recipe available. Check out this guide for comprehensive instructions. ```ruby{4-6} # config/routes.rb Rails.application.routes.draw do scope ":locale" do mount_avo end end ``` :::info To guarantee that the `locale` scope is included in the `default_url_options`, you must explicitly add it to the Avo configuration. Check this documentation section for details on how to configure `default_url_options` setting. ::: ## Mount Avo under a nested path You may need to mount Avo under a nested path, something like `/uk/admin`. In order to do that, you need to consider a few things. 1. Move the engine mount point below any route for custom tools. ```ruby{7,10} # config/routes.rb Rails.application.routes.draw do # other routes authenticate :user, ->(user) { user.is_admin? } do scope :uk do scope :admin do get "dashboard", to: "avo/tools#dashboard" # custom tool added before engine end mount_avo # engine mounted last end end end ``` 2. The `root_path` configuration should only be the last path segment. ```ruby # 🚫 Don't add the scope to the root_path Avo.configure do |config| config.root_path = "/uk/admin" end # βœ… Do this instead Avo.configure do |config| config.root_path = "/admin" end ``` 3. Use full paths for other configurations. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.home_path = "/uk/admin/dashboard" config.set_initial_breadcrumbs do add_breadcrumb "Dashboard", "/uk/admin/dashboard" end end ``` ## Serve Avo from a custom `map` in `config.ru` If you serve your Rails app under a prefix through a custom `map` block in `config.ru`, set `prefix_path` to that mapping's prefix so Avo generates correct paths. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.prefix_path = "/internal" end ``` ## Namespaced resource routes A namespaced resource like `Avo::Resources::Galaxy::Planet` gets a slash-joined route path derived from its class name β€” `galaxy/planets` in this case. Avo generates and mounts these routes for you, so there's nothing to configure by hand. ## Add your own routes You may want to add your own routes inside Avo so you can access different custom actions that you might have set in the Avo resource controllers. You can do that in your app's `routes.rb` file by opening up the Avo routes block and append your own. ```ruby # config/routes.rb Rails.application.routes.draw do mount_avo # your other app routes end if defined? ::Avo Avo::Engine.routes.draw do # new route in new controller put "switch_accounts/:id", to: "switch_accounts#update", as: :switch_account scope :resources do # append a route to a resource controller get "courses/cities", to: "courses#cities" end end end # app/controllers/avo/switch_accounts_controller.rb class Avo::SwitchAccountsController < Avo::ApplicationController def update session[:tenant_id] = params[:id] redirect_back fallback_location: root_path end end ``` --- # Gem server authentication Avo comes in a few tiers. The Community tier is a free gem available on rubygems.org, and a few paid tiers ship as private gems hosted on our own private gems server ([`packager.dev`](https://packager.dev)). To access the paid gems you must authenticate using the **Gem Server Token** found on your [dashboard](https://avohq.io/dashboard). There are a few ways to do that. We focus on the most important and secure ones: [on the server and CI systems](#on-the-server-and-ci-systems) and [on your local development environment](#on-your-local-development-environment). :::info We use the `xxx` notation instead of the actual gem server token. ::: ## On the server and CI systems :::info Recommendation This is the recommended way for most use cases. ::: The best way is to register this environment variable so bundler knows to use it when pulling packages from [`packager.dev`](https://packager.dev). ```bash export BUNDLE_PACKAGER__DEV=xxx # or BUNDLE_PACKAGER__DEV=xxx bundle install ``` Each hosting service has its own way to add environment variables. See how to do it on [Heroku](#heroku), [Hatchbox](#hatchbox), [GitHub Actions](#github-actions), [Docker](#docker-and-docker-compose) or [Kamal](#kamal). :::warning Warning about using the `.env` file You might be tempted to add the token to your `.env` file, as you might do with your Rails app. That will not work because `bundler` will not automatically load those environment variables. Add the environment variable through the service's dedicated page or by running the `export` command before `bundle install`. ::: ### Heroku Set the environment variable with the following command. This way `bundler` uses it when authenticating to `packager.dev`. ```bash heroku config:set BUNDLE_PACKAGER__DEV=xxx ``` ### Hatchbox Set the environment variable in your app's "Environment" tab. This way `bundler` uses it when authenticating to `packager.dev`. ```yaml BUNDLE_PACKAGER__DEV: xxx ``` ### GitHub Actions You might need to install Avo's paid gems in your GitHub Actions pipeline. There are two steps to enable that. #### 1. Add `BUNDLE_PACKAGER__DEV` to your repository's secrets In your repo, go to Settings β†’ Secrets and Variables β†’ Actions β†’ New repository secret and add your gem server token with the name `BUNDLE_PACKAGER__DEV` and the token as the value. GitHub repository Actions secrets settings page Adding a new BUNDLE_PACKAGER__DEV repository secret #### 2. Expose `BUNDLE_PACKAGER__DEV` as an environment variable Then, in your workflow file, expose that configuration item as an environment variable. ```yml{9-10} # .github/workflows/test.yml name: Tests on: pull_request: branches: - main env: BUNDLE_PACKAGER__DEV: ${{secrets.BUNDLE_PACKAGER__DEV}} jobs: test: runs-on: ubuntu-latest steps: # Testing and deployment steps ``` ### Docker and Docker Compose Build with Docker by passing a build argument from your environment. ```dockerfile{9,11} # Dockerfile FROM ruby:3.2.2 RUN apt-get update -qq && apt-get install -y nodejs postgresql-client WORKDIR /app COPY Gemfile /app/Gemfile COPY Gemfile.lock /app/Gemfile.lock # get the build argument ARG BUNDLE_PACKAGER__DEV # make it available in the docker image ENV BUNDLE_PACKAGER__DEV=$BUNDLE_PACKAGER__DEV RUN bundle install COPY . /app # do more stuff ``` ```bash # Pass the key to the build argument docker build --build-arg BUNDLE_PACKAGER__DEV=xxx # OR # Set the key as an environment variable on your machine # Somewhere in your `.bashrc` or `.bash_profile` file export BUNDLE_PACKAGER__DEV=xxx # Then pass it to the build argument from there docker build --build-arg BUNDLE_PACKAGER__DEV=$BUNDLE_PACKAGER__DEV ``` ```bash docker compose build --build-arg BUNDLE_PACKAGER__DEV=xxx ``` ### Kamal Kamal setup is very similar to Docker: include `BUNDLE_PACKAGER__DEV` in your secrets and then use it in your `Dockerfile`. In your `deploy.yml`: ```yaml # config/deploy.yml # Configure builder setup. builder: arch: amd64 secrets: - BUNDLE_PACKAGER__DEV ``` Then in `.kamal/secrets`: ```bash # .kamal/secrets # However you set your secrets in Kamal BUNDLE_PACKAGER__DEV=xxx ``` Finally, in your `Dockerfile`: ```dockerfile # Dockerfile # Install application gems COPY Gemfile Gemfile.lock ./ RUN --mount=type=secret,id=BUNDLE_PACKAGER__DEV BUNDLE_PACKAGER__DEV=$(cat /run/secrets/BUNDLE_PACKAGER__DEV) bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile ``` ## On your local development environment For your local development environment, add the token to the default bundler configuration. This way `bundler` is aware of it without having to specify it in the `Gemfile`. ```bash bundle config set --global https://packager.dev/avo-hq/ xxx ``` ## Add Avo to your `Gemfile` Now you are ready to add Avo to your `Gemfile`. ```ruby # Avo Community gem "avo", ">= 4.0.0" # If you're on a paid plan, add the feature gems included in your license. source "https://packager.dev/avo-hq/" do # all or some of these gem "avo-authorization", ">= 4.0.0" gem "avo-advanced_search", ">= 4.0.0" gem "avo-advanced_file_uploads", ">= 4.0.0" gem "avo-record_reordering", ">= 4.0.0" gem "avo-menu_editor", ">= 4.0.0" gem "avo-menu", ">= 4.0.0" gem "avo-dashboards", ">= 4.0.0" gem "avo-http_resource", ">= 4.0.0" gem "avo-dynamic_filters", ">= 4.0.0" gem "avo-nested", ">= 4.0.0" gem "avo-collaboration", ">= 4.0.0" gem "avo-forms", ">= 4.0.0" gem "avo-kanban", ">= 4.0.0" gem "avo-api", ">= 4.0.0" gem "avo-reactive_fields", ">= 4.0.0" gem "avo-notifications", ">= 4.0.0" end ``` Run `bundle install` and `bundler` picks up the token and uses it to authenticate on the server. ## Bundle without the paid gems If you need to distribute your Rails app without the paid gems, move them to an optional group. ```bash RAILS_GROUPS=avo BUNDLE_WITH=avo bundle install ``` ```ruby # Gemfile gem "avo" group :avo, optional: true do source "https://packager.dev/avo-hq/" do gem "avo-advanced", "~> 4.0" end end ``` ## FAQ ### `Forbidden 403` If you're seeing this error `Retrying download gem from https://packager.dev/avo-hq/ due to error (1/4): Gem::RemoteFetcher::FetchError bad response Forbidden 403`, this probably means that bundler does not have access to the `BUNDLE_PACKAGER__DEV` environment variable. Read the guides above on how to set it on your development machine and in deployment scenarios. ### `Forbidden 403` in a sandboxed or cloud environment (Cursor Cloud) If the token is set correctly but you still get a `403 Forbidden` inside a sandboxed or cloud environment (for example the Claude Code cloud environment, Cursor's background/cloud agents, or any setup with restricted network egress), the request to `packager.dev` is likely being blocked by a network allowlist rather than failing authentication. Add `packager.dev` to the environment's list of allowed hosts and run `bundle install` again. --- # License troubleshooting There might be times when the configurations isn't up to date and you'd like to troubleshoot it. There are a couple of things you can do to perform a self-diagnostics session. ## Check the license status page Every Avo app has the license status page where you can see a few things about your license and the response from the license checking server. Go to `https://yourapp.com/avo/avo_private/status`. If you mounted Avo under a different path (like `admin`) it will be `https://yourapp.com/admin/avo_private/status`. In order to see that page your user has to be an an admin in Avo. Follow this guide to mark your user as an admin. This should tell you if the license authenticated correctly and what was the response from our checking server. The license key is hidden by default for security β€” set `exclude_from_status` to `[]` in your Avo initializer if you need to see it on the status page. ## Frequent issues --- # Authentication With Avo, you have the flexibility to build apps either with or without authentication. While Avo has minimal assumptions about its users, a few guidelines still apply: 1. Users can be either authenticated or not. Avo apps can be developed without requiring user authentication. 2. If you choose to implement authentication, you need to [define the current_user](#customize-the-current-user-method). 3. You can assign [lightweight roles](#user-roles) to your users. 4. Any authentication strategy or gem of your choice can be utilized. :::info Rails 8 authentication scaffold In essence, the [authentication scaffold](https://github.com/rails/rails/pull/52328) that Rails 8 comes with is custom authentication so we need to do a few things to ensure it's working. Please follow this guide to enable it. ::: ## Customize the `current_user` method Avo will not assume your authentication provider (the `current_user` method returns `nil`). That means that you have to tell Avo who the `current_user` is. ## Customize the profile widget The sidebar footer shows a small profile widget displaying three pieces of information about the current user β€” `name`, `photo`, and `title` β€” all read from the object returned by your [`current_user_method`](#customize-the-current-user-method). - **Name:** Avo calls the `name` method. If the object doesn't respond to it, it tries `email`, then falls back to `Avo user`. - **Photo:** the `avatar` method, used as the `src` of the photo. - **Title:** the `avo_title` method, displayed under the name. ## Customize the sign-out link If your app responds to `destroy_user_session_path`, a sign-out menu item will be added on the bottom sidebar (when you click the three dots). If your app does not respond to this method, the link will be hidden unless you provide a custom sign-out path. There are two ways to customize the sign-out path. ### Customize the current user resource name You can customize just the "user" part of the path name by setting `current_user_resource_name`. For example if you follow the `User` -> `current_user` convention, you might have a `destroy_current_user_session_path` that logs the user out. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.current_user_resource_name = :current_user end ``` Or if your app provides a `destroy_current_admin_session_path` then you would need to set `current_user_resource_name` to `current_admin`. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.current_user_resource_name = :current_admin end ``` ### Customize the entire sign-out path Alternatively, you can customize the sign-out path name completely by setting `sign_out_path_name`. For example, if your app provides `logout_path` then you would pass this name to `sign_out_path_name`. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.sign_out_path_name = :logout_path end ``` If both `current_user_resource_name` and `sign_out_path_name` are set, `sign_out_path_name` takes precedence. ## Filter out requests You probably do not want to allow Avo access to everybody. If you're using [devise](https://github.com/heartcombo/devise) in your app, use this block to filter out requests in your `routes.rb` file. ```ruby authenticate :user do mount_avo at: '/avo' end ``` You may also add custom user validation such as `user.admin?` to only permit a subset of users to your Avo instance. ```ruby authenticate :user, -> user { user.admin? } do mount_avo at: '/avo' end ``` Check out more examples of authentication on [sidekiq's authentication section](https://github.com/mperham/sidekiq/wiki/Monitoring#authentication). ## `authenticate_with` method Alternatively, you can use the `authenticate_with` config attribute. It takes a block and evaluates it in Avo's `ApplicationController` as a `before_action`. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.authenticate_with do authenticate_admin_user end end ``` Note that Avo's `ApplicationController` does not inherit from your app's `ApplicationController`, so any protected methods you defined would not work. Instead, you would need to explicitly write the authentication logic in the block. For example, if you store your `user_id` in the session hash, then you can do: ```ruby # config/initializers/avo.rb Avo.configure do |config| config.authenticate_with do redirect_to '/' unless session[:user_id] == 1 # hard code user ids here end end ``` ## User roles There might be cases where you want to signal to Avo that the current user has a role. Avo roles are very lightweight and that's for a reason. Building with roles in mind requires a bit more integration with the parent app. This is something that you can definitely take upon yourself to build if you need it. There are two extra roles that you can give to a user, besides the regular user. These roles can be assigned with a check on the `current_user` object. ### Customize the methods that check for roles You may customize the methods that Avo uses to assign roles in the initializer. ```ruby # config/avo.rb Avo.configure do |config| config.is_admin_method = :is_admin? config.is_developer_method = :is_developer? end ``` ## Authorization When you share access to Avo with your clients or large teams, you may want to restrict access to a resource or a subset of resources. You should set up your authorization rules (policies) to do that. Check out the authorization page for details on how to set that up. --- # Authorization When you share access to Avo with your clients or large teams, you may want to restrict access to a resource or a subset of resources. One example may be that only admin-level users may delete or update records. Avo provides a [Pundit](https://github.com/varvet/pundit) client out of the box for authorization that uses a policy system to manage access. :::info Pundit alternative Pundit is just the default client. You may plug in your own client using the instructions [here](#custom-authorization-clients). You can use [this](https://github.com/avo-hq/avo/issues/1922) `action_policy` client as well. ::: :::warning You must manually require `pundit` or your authorization library in your `Gemfile`. ```ruby # Minimal authorization through OO design and pure Ruby classes gem "pundit" ``` And update config/initializers/avo.rb with following configuration: ```ruby # Example of enabling authorization client in Avo configuration config.authorization_client = :pundit ``` ::: ## Ensure Avo knows who your current user is Before setting any policies up, please ensure Avo knows your current user. Usually, this πŸ‘‡ set up should be fine, but follow the authentication guide for more information. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.current_user_method = :current_user end ``` ## Policies Just run the regular pundit `bin/rails g pundit:policy Post` to generate a new policy. **If this is a new app you need to install pundit first bin/rails g pundit:install.** With this new policy, you may control what every type of user can do with Avo. The policy has the default methods for the regular controller actions: `index?`, `show?`, `create?`, `new?`, `update?`, `edit?` and `destroy?`. These methods control whether the resource appears on the sidebar, if the view/edit/destroy buttons are visible or if a user has access to those index/show/edit/create pages. Here's a full example of a policy file with all the methods Avo checks. Each one is described in detail below. ```ruby # app/policies/post_policy.rb class PostPolicy < ApplicationPolicy def index? true end def show? true end def new? create? end def create? user.admin? end def edit? true end def update? user.admin? end def destroy? user.admin? end def act_on? user.admin? end def reorder? user.admin? end def search? true end def preview? true end end ``` An Avo Posts index table with the View (eye) icon in a row's action controls highlighted, the control the show? policy governs. ## Associations When using associations, you would like to set policies for `creating` new records on the association, allowing to `attach`, `detach`, `create` or `destroy` relevant records. Again, Avo makes this easy using a straightforward naming schema. :::warning Make sure you use the same pluralization as the association name. For a `has_many :users` association use the plural version method `view_users?`, `edit_users?`, `detach_users?`, etc., not the singular version `detach_user?`. ::: ### Example scenario We'll have this example of a `Post` resource with many `Comment`s through the `has_many :comments` association. :::info The `record` variable in policy methods In the `Post` `has_many` `Comments` example, when you want to authorize `show_comments?` in `PostPolicy` you will have a `Comment` instance as the `record` variable, but when you try to authorize the `attach_comments?`, you won't have that `Comment` instance because you want to create one, but we expose the parent `Post` instance so you have more information about that authorization action that you're trying to make. ::: ## Removing duplication :::info A note on duplication Let's take the following example: A `User` has many `Contract`s. And you represent that in your Avo resource. How do you handle authorization to the `ContractResource`? For one, you set the `ContractPolicy.index?` and `ContractPolicy.edit?` methods to `false` so regular users don't have access to all contracts (see and edit), and the `UserPolicy.view_contracts?` and `UserPolicy.edit_contracts?` set to `false`, because, when viewing a user you want to see all the contracts associated with that user and don't let them edit it. You might be thinking that there's code duplication here. "Why do I need to set a different rule for `UserPolicy.edit_contracts?` when I already set the `ContractPolicy.edit?` to `false`? Isn't that going to take precedence?" Now, let's imagine we have a user that is an admin in the application. The business need is that an admin has access to all contracts and can edit them. This is when we go back to the `ContractPolicy.edit?` and turn that to true for the admin user. And now we can separately control who and where a user can edit a contract. ::: You may remove duplication by applying the same policy rule from the original policy. ```ruby class CommentPolicy # ... more policy methods def edit record.user_id == current_user.id end end class PostPolicy # ... more policy methods def edit_comments? Pundit.policy!(user, record).edit? end end ``` Now, whatever action you take for one comment, it will be available for the `edit_comments?` method in `PostPolicy`. There's a concern that removes the duplication and helps you apply the same rules to associations. You should include `Avo::Authorization::Concerns::PolicyHelpers` in the `ApplicationPolicy` for it to be applied to all policy classes. `PolicyHelpers` allows you to use the method `inherit_association_from_policy`. This method takes two arguments; `association_name` and the policy file you want to be used as a template. ```ruby inherit_association_from_policy :comments, CommentPolicy ``` With just one line of code, it will define the following methods to policy your association: ```ruby def create_comments? CommentPolicy.new(user, record).create? end def edit_comments? CommentPolicy.new(user, record).edit? end def update_comments? CommentPolicy.new(user, record).update? end def destroy_comments? CommentPolicy.new(user, record).destroy? end def show_comments? CommentPolicy.new(user, record).show? end def reorder_comments? CommentPolicy.new(user, record).reorder? end def act_on_comments? CommentPolicy.new(user, record).act_on? end def view_comments? CommentPolicy.new(user, record).index? end def attach_comments? CommentPolicy.new(user, record).attach? end def detach_comments? CommentPolicy.new(user, record).detach? end ``` Although these methods won't be visible in your policy code, you can still override them. For instance, if you include the following code in your `CommentPolicy`, it will be executed in place of the one defined by the helper: ```ruby inherit_association_from_policy :comments, CommentPolicy def destroy_comments? false end ``` ## Attachments When working with files, it may be necessary to establish policies that determine whether users can `upload`, `download` or `delete` files. Fortunately, Avo simplifies this process by providing a straightforward naming schema for these policies. Both the `record` and the `user` will be available for you to access. :::info Actions inherit attachment authorization These attachment authorization methods also apply to file fields in actions that run on the resource using the same policy. For example, if you define `upload_file?` in `PostPolicy` and have an action on `Avo::Resources::Post` with `field :file, as: :file`, the same `upload_file?` policy method will be used to authorize the file upload in that action. ::: :::info AUTHORIZE IN BULK If you want to allow or disallow these methods in bulk you can use a little meta-programming to assign all the same value. ```ruby [:cover_photo, :audio].each do |file| [:upload, :download, :delete].each do |action| define_method "#{action}_#{file}?" do true end end end ``` ::: ## Scopes You may specify a scope for the , , and views. ```ruby{3-9} class PostPolicy < ApplicationPolicy class Scope < Scope def resolve if user.admin? scope.all else scope.where(published: true) end end end end ``` :::warning This scope will be applied only to the view of Avo. It will not be applied to the association view. Example: A `Post` has_many `Comment`s. The `CommentPolicy::Scope` will not affect the `has_many` field. You need to add the `scope` option to the `has_many` field where you can modify the query. ```ruby # The `parent` is the Post instance that the user is seeing. ex: Post.find(1) # The `query` is the Active Record query being done on the comments. ex: post.comments field :comments, as: :has_many, scope: -> { Pundit.policy_scope(parent, query) } ``` ::: ## Using different policy methods By default Avo will use the generated Pundit methods (`index?`, `show?`, `create?`, `new?`, `update?`, `edit?` and `destroy?`). But maybe, in your app, you're already using these methods and would like to use different ones for Avo. You may want override these methods inside your configuration with a simple map using the `authorization_methods` key. ```ruby{6-14} Avo.configure do |config| config.root_path = '/avo' config.app_name = 'Avocadelicious' config.license_key = ENV['AVO_LICENSE_KEY'] config.authorization_methods = { index: 'avo_index?', show: 'avo_show?', edit: 'avo_edit?', new: 'avo_new?', update: 'avo_update?', create: 'avo_create?', destroy: 'avo_destroy?', search: 'avo_search?', } end ``` Now, Avo will use `avo_index?` instead of `index?` to manage the **Index** view authorization. ## Use Resource's Policy to authorize custom actions It may be necessary to authorize a specific field or custom action of a resource using a policy class rather than defining the authorization logic directly within the resource class. By doing so, we can delegate control to the policy class, ensuring a cleaner and more maintainable authorization structure. :::code-group ```ruby [app/resources/product.rb]{8} field :amount, as: :money, currencies: %w[USD], sortable: true, filterable: true, copyable: true, # define ability to change the amount in policy class instead of doing it here disabled: -> { !@resource.authorization.authorize_action(:amount?, raise_exception: false) } ``` ```ruby [app/policies/product_policy.rb]{2-4} # Define ability to change the amount in Product Policy def amount? user.admin? end ``` ::: ## Raise errors when policies are missing The default behavior of Avo is to allow missing policies for resources silently. So, if you have a `User` model and a `Avo::Resources::User` but don't have a `UserPolicy`, Avo will not raise errors regarding missing policies and authorize that resource. If, however, you need to be on the safe side of things and raise errors when a Resource is missing a Policy, you can toggle on the `raise_error_on_missing_policy` configuration. ```ruby{7} # config/initializers/avo.rb Avo.configure do |config| config.root_path = '/avo' config.app_name = 'Avocadelicious' config.license_key = ENV['AVO_LICENSE_KEY'] config.raise_error_on_missing_policy = true end ``` Now, you'll have to provide a policy for each resource you have in your app, thus making it a more secure app. ## Logs Developers have the ability to monitor any unauthorized actions. When a developer user makes a request that triggers an unauthorized action, a log entry similar to the following will be generated: In development each log entry provides details about the policy class, the action attempted, the global id of the user who made the request, and the global id of the record involved: ```bash web | [Avo->] Unauthorized action 'reorder?' for 'UserPolicy' web | user: gid://dummy/User/20 web | record: gid://dummy/User/31 ``` To find a record based on its global id you can use `GlobalID::Locator.locate` ```ruby gid = "gid://dummy/User/20" user = GlobalID::Locator.locate(gid) ``` In production each log entry provides details only about the policy class and the attempted action: ```bash web | [Avo->] Unauthorized action 'act_on?' for 'UserPolicy' ``` ## Custom policies By default, Avo will infer the policy from the model of the resource object. If you wish to use a different policy for a given resource, you can specify it directly in the resource using the `authorization_policy` option. ```ruby # app/avo/resources/photo_comment.rb class Avo::Resources::PhotoComment < Avo::BaseResource self.model_class = "Comment" self.authorization_policy = PhotoCommentPolicy # ... end ``` ## Custom authorization clients Pundit is the default client, but you can plug in any authorization library (for example [Action Policy](https://github.com/palkan/action_policy)) by implementing a small adapter class. :::info Reference implementation The Pundit adapter examples below follow the same contract as Avo's built-in `:pundit` client. Community examples for Action Policy are available in [this issue](https://github.com/avo-hq/avo/issues/1922). ::: ### How Avo uses your client Your client is a thin adapter between Avo and your authorization library. Avo calls four methods on it: #### `policy(user, record)` Finds the policy for a model class or record. Avo calls this before running authorization checks β€” for example to resolve `EquipmentPolicy` for the `Equipment` model or a specific record. ```ruby policy(user, Equipment) # => EquipmentPolicy instance policy(user, equipment_record) # => EquipmentPolicy instance ``` --- #### `authorize(user, record, action, policy_class:, raise_exception:, **kwargs)` Checks whether the user can perform an action β€” for example `"index?"`, `"new?"`, or `"create?"`. Avo calls this for sidebar items, buttons, menu visibility, and controller requests. The `raise_exception` keyword tells Avo how to handle a denial: - **UI visibility checks** (sidebar, buttons, menu) β€” Avo passes `raise_exception: false`. Your client should still **raise** on denial. Avo catches the exception and returns `false` to hide the element. - **Controller requests** β€” Avo omits `raise_exception`. Your client should raise on denial and Avo will show the unauthorized page. --- #### `apply_policy(user, model, policy_class:)` Scopes a query to records the user is allowed to see. Avo calls this on , , and views to filter the underlying query β€” for example returning only published posts for non-admin users. :::tip Menu editor You can call `authorize` yourself in the menu editor `visible` block. It delegates to the same client configured in `config.authorization_client`. ```ruby authorize current_user, Team, "index?", raise_exception: false ``` ::: ### Change the authorization client In order to use a different client change the `authorization_client` option in the initializer. The built-in possible values are `nil` and `:pundit`. When you create your own client, pass the class name. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.authorization_client = "Avo::ActionPolicyAuthorizationClient" end ``` ### Client methods Each authorization client must expose four methods. **All method signatures must accept keyword arguments you do not use** (for example `raise_exception:` and `policy_class:`). If your `authorize` method does not accept these keywords, Ruby will raise an `ArgumentError`. When that happens inside a UI visibility check, Avo may treat the action as unauthorized without a clear error message. :::info Accept extra keywords (and ignore them) Your client should accept extra keyword arguments (usually via `**`) for forward compatibility. For example, Avo may pass `raise_exception:` for UI checks and other keys (like `resource_class:`) for logging. Your client should generally **ignore** these flags and focus on raising the correct Avo errors on missing policy / denial. ::: :::warning Raise on denial β€” do not return `false` When authorization fails, your client's `authorize` method **must raise an exception**. Avo does not use the return value of `authorize`. When Avo passes `raise_exception: false`, it still expects your client to raise on denial. Avo catches the exception and returns `false` to the caller. If your client returns `false` instead of raising, Avo will treat the action as **authorized**. This is how the built-in Pundit client works β€” `Pundit.authorize` always raises `Pundit::NotAuthorizedError` when access is denied. ::: #### `authorize` Receives the `user`, `record`, `action`, and optionally `policy_class`, `raise_exception`, and other keyword arguments. Map authorization failures to `Avo::NotAuthorizedError` and missing policies to `Avo::NoPolicyError`. ```ruby # Pundit example def authorize(user, record, action, policy_class: nil, **) Pundit.authorize(user, record, action, policy_class: policy_class) rescue Pundit::NotDefinedError => error raise Avo::NoPolicyError, error.message rescue Pundit::NotAuthorizedError => error raise Avo::NotAuthorizedError, error.message end ``` --- #### `policy` Receives the `user` and `record` and returns the policy instance to use. Return `nil` when no policy exists. ```ruby def policy(user, record, **) Pundit.policy(user, record) end ``` --- #### `policy!` Receives the `user` and `record` and returns the policy instance. Raise `Avo::NoPolicyError` when no policy is found. ```ruby def policy!(user, record, **) Pundit.policy!(user, record) rescue Pundit::NotDefinedError => error raise Avo::NoPolicyError, error.message end ``` --- #### `apply_policy` Receives the `user`, the query to scope (usually an Active Record relation or model class), and optionally the policy class to use. ```ruby def apply_policy(user, model, policy_class: nil, **) scope_from_policy_class = scope_for_policy_class(policy_class) if scope_from_policy_class.present? scope_from_policy_class.new(user, model).resolve else Pundit.policy_scope!(user, model) end rescue Pundit::NotDefinedError => error raise Avo::NoPolicyError, error.message end ``` ### Action Policy example Here is a complete Action Policy client that follows Avo's contract: ```ruby # app/services/avo/action_policy_authorization_client.rb module Avo class ActionPolicyAuthorizationClient include ::ActionPolicy::Behaviour authorize :user attr_accessor :user def authorize(user, record, action, policy_class: nil, **) self.user = user authorize!(record, to: action, with: policy_class) rescue ActionPolicy::Unauthorized => error raise Avo::NotAuthorizedError, error.message end def policy(user, record, **) policy!(user, record) rescue Avo::NoPolicyError nil end def policy!(user, record, **) self.user = user policy_for(record:) rescue ActionPolicy::NotFound => error raise Avo::NoPolicyError, error.message end def apply_policy(user, model, policy_class: nil, **) policy = if policy_class.present? policy_class.new(model, user:) else policy!(user, model) end policy.apply_scope(model, type: :active_record_relation) end end end ``` Place your policies under the `Avo` namespace (for example `Avo::EquipmentPolicy`) or configure Action Policy's lookup to match your app. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.authorization_client = "Avo::ActionPolicyAuthorizationClient" end ``` ```ruby # app/policies/avo/equipment_policy.rb module Avo class EquipmentPolicy < ApplicationPolicy def index? true end def new? user.admin? end def create? user.admin? end end end ``` ## Explicit authorization ## Rolify integration Check out this guide to add rolify role management with Avo. --- # Performance Avo uses the application's cache system to enhance performance. The cache system is especially beneficial when dealing with resource index tables and license requests. ## Cache store selection Avo picks its cache store based on the environment: - **Production** β€” Avo uses `Rails.cache`, unless it's one of `ActiveSupport::Cache::MemoryStore` or `ActiveSupport::Cache::NullStore`. In that case it falls back to the `:file_store` with a cache path of `tmp/cache`. - **All other environments** (development, test, and any custom environment) β€” Avo uses the `:file_store` with a cache path of `tmp/cache`. :::warning `MemoryStore` in production Our recommendation is to not use `MemoryStore` in production because it will not be shared between multiple processes (when using Puma). That's why Avo rejects it and falls back to the `:file_store`. ::: ### Custom selection You can force Avo to use a particular store. ```ruby # config/initializers/avo.rb config.cache_store = -> { ActiveSupport::Cache.lookup_store(:solid_cache_store) } # or config.cache_store = ActiveSupport::Cache.lookup_store(:solid_cache_store) ``` The `cache_store` configuration option expects a cache store object. The lambda syntax can be useful if different stores are desired on different environments. ## Row caching Avo caches each record on the view (and each item on the Grid view) for improved performance. ## Caching caveats Because Avo caches each record on the view, some side-effects may occur. We'll try to outline some of them below and keep this page up to date as we find them or as they get reported to us. These are things that may happen to regular Rails apps, not just in the Avo context. ### Rows may not be automatically updated when certain associations change There are two things you could do to prevent this: #### Option 1: Use `touch: true` on association Example with Parent Model and Association ```ruby class Post < ApplicationRecord has_many :comments, dependent: :destroy end ``` Example with Child Model and Association with `touch: true` ```ruby class Comment < ApplicationRecord belongs_to :post, touch: true end ``` #### Option 2: override `cache_hash` method on resource to take associations in consideration Avo, internally, uses the `cache_hash` method to compute the hash that will be remembered by the caching driver when displaying the rows. You can take control and override it on that particular resource to take the association into account. ```ruby class Avo::Resources::User < Avo::BaseResource def fields # your fields end def cache_hash(parent_record) # record.post will now be taken under consideration result = [record, file_hash, record.post] if parent_record.present? result << parent_record end result end end ``` ### `root_path` change won't break the cache keys When the rows are cached, the links from the controls, `belongs_to` and `record_link` fields, and maybe others will be cached along. The best solution here is to clear the cache with this ruby command `Rails.cache.clear`. If that's not an option then you can try to add the `root_path` to the `cache_hash` method in your particular resource. ## Solid Cache Avo seamlessly integrates with [Solid Cache](https://github.com/rails/solid_cache). To setup Solid Cache follow these essential steps Add this line to your application's Gemfile: ```ruby gem "solid_cache" ``` And then execute: ```bash $ bundle ``` Or install it yourself as: ```bash $ gem install solid_cache ``` Add the migration to your app: ```bash $ bin/rails solid_cache:install:migrations ``` Then run it: ```bash $ bin/rails db:migrate ``` To set Solid Cache as your Rails cache, you should add this to your environment config: ```ruby # config/environments/production.rb config.cache_store = :solid_cache_store ``` Check [Solid Cache repository](https://github.com/rails/solid_cache) for additional valuable information. ## Log ViewComponent loading times and allocations Sometimes, you may want to track the loading times and memory allocations of ViewComponents, similar to how you do with partials. Follow these two steps to enable this functionality. #### 1. Enable ViewComponent Instrumentation First, you need to enable instrumentation for ViewComponents. Add the following configuration to your `application.rb` or `development.rb` file: ```ruby # application.rb or development.rb config.view_component.instrumentation_enabled = true ``` #### 2. Add Logging Next, set up logging to capture the performance data. Create or update the `config/initializers/view_component.rb` file with the following code: ```ruby # config/initializers/view_component.rb module ViewComponent class LogSubscriber < ActiveSupport::LogSubscriber define_method :'!render' do |event| info do message = +" Rendered #{event.payload[:name]}" message << " (Duration: #{event.duration.round(1)}ms" message << " | Allocations: #{event.allocations})" end end end end ViewComponent::LogSubscriber.attach_to :view_component ``` View Component logging :::warning Enabling this logging can negatively impact your application's performance. We recommend using it in the development environment or disabling it in production once you have completed debugging. ::: --- # Resources Avo effortlessly empowers you to build an entire customer-facing interface for your Ruby on Rails application. One of the most powerful features is how easy you can administer your database records using the CRUD UI. ## Overview Similar to how you configure your database layer using the Rails models and their DSL, Avo's CRUD UI is configured using `Resource` files. Each `Resource` maps out one of your models. There can be multiple `Resource`s associated to the same model if you need that. All resources are located in the `app/avo/resources` directory. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource def fields field :id, as: :id field :name, as: :text end end ``` From this file alone, Avo infers the model (`Post`), the resource name, the routes, and adds the resource to the sidebar. Everything inferred can be overridden through the resource options. ## Generate resources ### Alongside a model ```bash bin/rails generate model car make:string mileage:integer ``` Running this command will generate the standard Rails files (model, controller, etc.) and `Avo::Resources::Car` & `Avo::CarsController` for Avo. The auto-generated resource file will look like this: ```ruby # app/avo/resources/car.rb class Avo::Resources::Car < Avo::BaseResource self.includes = [] # self.search = { # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } # } def fields field :id, as: :id field :make, as: :text field :mileage, as: :number end end ``` The auto-generated controller will look like this: ```ruby # app/controllers/avo/cars_controller.rb class Avo::CarsController < Avo::ResourcesController end ``` The Avo Resource should always be accompanied by a controller. If you don't want the Avo counterpart, pass `--skip-avo-resource`: ```bash bin/rails generate model car make:string kms:integer --skip-avo-resource ``` ### With the resource generator For an existing model, generate the resource directly: ```bash bin/rails generate avo:resource post ``` This command will generate the `Post` resource file in `app/avo/resources/post.rb` with the following code: ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.includes = [] # self.search = { # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } # } def fields field :id, as: :id end end ``` If the `Post` model is already well defined with attributes and associations, the resource will be generated with the matching fields: ::: code-group ```ruby [app/models/post.rb] # == Schema Information # # Table name: posts # # id :bigint not null, primary key # name :string # body :text # is_featured :boolean # published_at :datetime # user_id :bigint # created_at :datetime not null # updated_at :datetime not null # status :integer default("draft") # class Post < ApplicationRecord enum status: [:draft, :published, :archived] validates :name, presence: true has_one_attached :cover_photo has_one_attached :audio has_many_attached :attachments belongs_to :user, optional: true has_many :comments, as: :commentable has_many :reviews, as: :reviewable acts_as_taggable_on :tags end ``` ```ruby [app/avo/resources/post.rb] class Avo::Resources::Post < Avo::BaseResource self.includes = [] # self.search = { # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } # } def fields field :id, as: :id field :name, as: :text field :body, as: :textarea field :is_featured, as: :boolean field :published_at, as: :datetime field :user_id, as: :number field :status, as: :select, enum: ::Post.statuses field :cover_photo, as: :file field :audio, as: :file field :attachments, as: :files field :user, as: :belongs_to field :comments, as: :has_many field :reviews, as: :has_many field :tags, as: :tags end end ``` ::: If you want the resource to use a different model, pass `--model-class`. For example, to create a `MiniPost` resource backed by the `Post` model: ```bash bin/rails generate avo:resource mini-post --model-class post ``` That command will create a new resource with the same attributes as the post resource above, specifying `model_class`: ```ruby class Avo::Resources::MiniPost < Avo::BaseResource self.model_class = "Post" end ``` :::info You can see the result in the admin panel using this URL `/avo`. The `Post` resource will be visible on the left sidebar. ::: ### For all your models To generate Avo resources for all models in your application, run: ```bash bin/rails generate avo:all_resources ``` The generator scans your `app/models` directory, includes only classes that inherit from `ActiveRecord::Base`, excludes abstract classes (e.g. `ApplicationRecord`) and non-model files (concerns, POROs, `Current`, form objects), and runs the `avo:resource` generator for each match β€” printing an error message if generation fails for any model. This is particularly useful when setting up Avo in an existing Rails application or ensuring all your models have corresponding Avo resources. ## Fields `Resource` files tell Avo what records should be displayed in the UI, but not what kinds of data they hold. You do that using the `fields` method. Read more about the fields here. ```ruby{5-17} class Avo::Resources::Post < Avo::BaseResource self.title = :id self.includes = [] def fields field :id, as: :id field :name, as: :text, required: true field :body, as: :trix, placeholder: "Add the post body here", always_show: false field :cover_photo, as: :file, link_to_record: true field :is_featured, as: :boolean field :is_published, as: :boolean do record.published_at.present? end field :user, as: :belongs_to, placeholder: "β€”" end end ``` ## Routing Avo will automatically generate routes based on the resource name when generating a resource. ``` Avo::Resources::Post -> /avo/resources/posts Avo::Resources::PhotoComment -> /avo/resources/photo_comments ``` If you change the resource name, you should change the generated controller name too. ## Name and describe records Avo figures out a record's display name by trying the `name`, `title`, and `label` attributes in order, falling back to `id`. If that guess is wrong for your model, point `self.title` to another attribute: ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.title = :slug end ``` If no single attribute works as a title, assign a block instead β€” you have access to `record` and `resource`, so you can compose whatever reads best: ```ruby # app/avo/resources/comment.rb class Avo::Resources::Comment < Avo::BaseResource self.title = -> { ActionView::Base.full_sanitizer.sanitize(record.body).truncate 30 } end ``` To show a message to your users on the resource's pages, set `self.description` β€” a string for all views, or a block when the message depends on the `view`, `record`, or `current_user`: ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource self.description = "These are the users of the app." end ``` You can also set the record's avatar with `self.avatar` and the sidebar icon with `self.icon`. To make the resource easier to spot β€” or to visually group related resources β€” tint the sidebar icon with `self.color`: ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource self.avatar = :avatar self.icon = "tabler/outline/user" self.color = :purple end ``` ## Avoid n+1 queries If a resource displays associations or attachments, eager load them with `self.includes` and `self.attachments` to dodge `n+1` performance issues on the `Index` view: ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.includes = [:user, :tags] self.attachments = [:cover_photo] end ``` Use `self.single_includes` and `self.single_attachments` when you need the same eager loading on the and views. ## Control sorting Records on the `Index` view are sorted by `created_at`, descending. Change the column with `self.default_sort_column` and the direction with `self.default_sort_direction`: ```ruby # app/avo/resources/task.rb class Avo::Resources::Task < Avo::BaseResource self.default_sort_column = :position self.default_sort_direction = :asc end ``` If your model has a `default_scope` you don't want applied on the index, unscope it with `self.index_query`: ```ruby # app/avo/resources/project.rb class Avo::Resources::Project < Avo::BaseResource self.index_query = -> { query.unscoped } end ``` ## Customize how records are fetched If your records are identified by something other than the numeric `id` β€” a slug from a custom `to_param` method, for example β€” tell Avo how to find them with `self.find_record_method`: ::: code-group ```ruby [app/avo/resources/post.rb] class Avo::Resources::Post < Avo::BaseResource self.find_record_method = -> { # `id` is an Array in batch contexts (bulk actions), so return a collection there. if id.is_a?(Array) id.first.to_i == 0 ? query.where(slug: id) : query.where(id: id) else id.to_i == 0 ? query.find_by!(slug: id) : query.find(id) end } end ``` ```ruby [app/models/post.rb] class Post < ApplicationRecord before_save :update_slug def to_param slug || id end def update_slug self.slug = name.parameterize end end ``` ::: If you use a gem for custom IDs you likely don't need this at all β€” Avo detects [FriendlyId](https://github.com/norman/friendly_id) automatically, and [prefixed_ids](https://github.com/excid3/prefixed_ids) and [hashid-rails](https://github.com/jcypret/hashid-rails) work out of the box. See the custom IDs guide for the setup for each gem. ## Customize pagination On large tables, counting all records to render the pagination can get expensive. Switch `self.pagination` to the `:countless` type to skip the count entirely: ```ruby # app/avo/resources/log_entry.rb class Avo::Resources::LogEntry < Avo::BaseResource self.pagination = { type: :countless } end ``` The `slots` key controls how many page links are rendered β€” see the pagination reference for all the combinations. ## Control the saving flow If saving deserves a second thought, set `self.confirm_on_save` to ask users for confirmation before persisting: ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.confirm_on_save = true end ``` After creating or updating a record, Avo redirects to the view. Redirect somewhere else with `self.after_create_path` and `self.after_update_path`: ```ruby # app/avo/resources/comment.rb class Avo::Resources::Comment < Avo::BaseResource self.after_create_path = :index self.after_update_path = :edit end ``` For more granular control (custom paths, different responses), use the controller methods instead. If your forms grow tall, add the `Back` and `Save` buttons to the footer too with `config.buttons_on_form_footers`: ```ruby # config/initializers/avo.rb Avo.configure do |config| config.buttons_on_form_footers = true end ``` If you use `devise` and update users without passing a password, stop the validation error with `self.devise_password_optional`. ## Tweak the Index view Display records as a grid or on a map instead of a table with `self.default_view_type`: ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.default_view_type = :grid end ``` It also takes a block when the choice depends on the request β€” see the grid view and map view pages for what each type needs. A few more knobs for the `Index` view: - Hide the selection checkboxes with `self.record_selector` for resources that will never be selected. - Keep the filters panel open while users change filter values with `self.keep_filters_panel_open`. - For STI models, send users who click a parent record to the child record instead with `self.link_to_child_resource`. ```ruby # app/avo/resources/comment.rb class Avo::Resources::Comment < Avo::BaseResource self.record_selector = false self.keep_filters_panel_open = true end ``` For resources whose records are slow to load, switch to `self.index_view_loading = :lazy` so the page shell and controls paint immediately and the records stream in through a Turbo Frame: ```ruby # app/avo/resources/order.rb class Avo::Resources::Order < Avo::BaseResource self.index_view_loading = :lazy end ``` Search, filters, sorting, pagination, and view switches then stay asynchronous inside that frame while the URL keeps in sync. Loading stays eager on association indexes and on resources with a custom index component. ## Record previews Let users peek at a record from the `Index` view without opening it. Add a `preview` field to the resource and mark the fields you want in the popover with `show_on: :preview`. ## Manage sidebar presence and shortcuts The auto-generated sidebar lists every resource. Hide the ones users shouldn't navigate to directly with `self.visible_on_sidebar`, and give frequently visited resources a keyboard shortcut with `self.hotkey`: ```ruby # app/avo/resources/team_membership.rb class Avo::Resources::TeamMembership < Avo::BaseResource self.visible_on_sidebar = false end # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.hotkey = "g p" end ``` :::info `visible_on_sidebar` only affects the auto-generated menu. If you use the menu editor, control visibility with its `visible` block instead. ::: ## Link to the record's public page It's often desirable to give users a link to a record's public path outside the Avo interface. Configure `self.external_link` with a block returning the URL β€” your app's path helpers are available: ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.external_link = -> { main_app.post_path(record) } end ``` Avo will display an external link button on the record that takes the user to that URL. ## Swap the view components Each view is rendered by a ViewComponent (`Avo::Views::ResourceIndexComponent`, `ResourceShowComponent`, `ResourceEditComponent`). Replace any of them per resource with `self.components`: ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource self.components = { "Avo::Views::ResourceIndexComponent": Avo::Views::Users::ResourceIndexComponent } end ``` The easiest way to create a compatible component is to eject an existing one. The safely override resource components guide walks through the whole process. ## Use multiple resources for the same model Usually, an Avo Resource maps to one Rails model. So there will be a one-to-one relationship between them. But there will be scenarios where you'd like to create another resource for the same model. Let's take as an example the `User` model. You'll have an `User` resource associated with it. ```ruby # app/models/user.rb class User < ApplicationRecord end # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource self.title = :name def fields field :id, as: :id, link_to_record: true field :email, as: :gravatar, link_to_record: true, as_avatar: :circle field :first_name, as: :text, required: true, placeholder: "John" field :last_name, as: :text, required: true, placeholder: "Doe" end end ``` So when you click on the Users sidebar menu item, you get to the `Index` page where all the users will be displayed. The information displayed will be the gravatar image, the first and the last name. Let's say we have a `Team` model with many `User`s. You'll have a `Team` resource like so: ```ruby{12} # app/models/team.rb class Team < ApplicationRecord end # app/avo/resources/team.rb class Avo::Resources::Team < Avo::BaseResource self.title = :name def fields field :id, as: :id, link_to_record: true field :name, as: :text field :users, as: :has_many end end ``` From that configuration, Avo will figure out that the `users` field points to the `User` resource and will use that one to display the users. But, let's imagine that we don't want to display the gravatar on the `has_many` association, and we want to show the name on one column and the number of projects the user has on another column. We can create a different resource named `TeamUser` resource and add those fields. ```ruby # app/avo/resources/team_user.rb class Avo::Resources::TeamUser < Avo::BaseResource self.title = :name def fields field :id, as: :id, link_to_record: true field :name, as: :text field :projects_count, as: :number end end ``` We also need to update the `Team` resource to use the new `TeamUser` resource for reference. ```ruby # app/avo/resources/team.rb class Avo::Resources::Team < Avo::BaseResource self.title = :name def fields field :id, as: :id, link_to_record: true field :name, as: :text field :users, as: :has_many, use_resource: Avo::Resources::TeamUser end end ``` But now, if we visit the `Users` page, we will see the fields for the `TeamUser` resource instead of `User` resource, and that's because Avo fetches the resources in an alphabetical order, and `TeamUser` resource is before `User` resource. That's definitely not what we want. The same might happen if you reference the `User` in other associations throughout your resource files. To mitigate that, we are going to use the `model_resource_mapping` option to set the "default" resource for a model. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.model_resource_mapping = { 'User': 'Avo::Resources::User' } end ``` That will "shortcircuit" the regular alphabetical search and use the `User` resource every time we don't specify otherwise. We can still tell Avo which resource to use in other `has_many` or `has_and_belongs_to_many` associations with the `use_resource` option. ## Namespaced resources Resources can be namespaced by nesting them in subdirectories of `app/avo/resources`, mirroring the namespace with `::` in the class name. This is handy for grouping resources that belong to a namespaced model (`Galaxy::Planet`) or that you just want organized under a common prefix (`Billing::Invoice`). ```ruby # app/avo/resources/galaxy/planet.rb class Avo::Resources::Galaxy::Planet < Avo::BaseResource self.title = :name def fields field :id, as: :id field :name, as: :text field :satellites, as: :has_many end end ``` If the resource's namespace matches its model's namespace (`Avo::Resources::Galaxy::Planet` β†’ `Galaxy::Planet`), Avo infers the model class automatically β€” no `self.model_class` needed. If it doesn't, set `model_class` explicitly, same as with a flat resource: ```ruby class Avo::Resources::SuperDooperTrooperModel < Avo::BaseResource self.model_class = "Super::Dooper::Trooper::Model" end ``` Generate a namespaced resource (and its matching namespaced controller) the same way you'd generate a flat one, just with the namespace in the name: ```bash bin/rails generate avo:resource Galaxy::Planet ``` That creates `app/avo/resources/galaxy/planet.rb` (`Avo::Resources::Galaxy::Planet`) and `app/controllers/avo/galaxy/planets_controller.rb` (`Avo::Galaxy::PlanetsController`). Namespaces can go as deep as you need β€” `Universe::Cluster::Star::Comet` generates `app/avo/resources/universe/cluster/star/comet.rb`, and so on. Namespacing also affects the resource's routes and translation key: `Avo::Resources::Galaxy::Planet` gets the route path `galaxy/planets` and the translation key `avo.resource_translations.galaxy/planet`, following the same underscored, slash-joined convention as the class name. ## Views Please read the detailed views page. ## Extending `Avo::ResourcesController` You may need to execute additional actions on the `ResourcesController` before loading the Avo pages. You can create an `Avo::BaseResourcesController` and extend your resource controller from it. ```ruby # app/controllers/avo/base_resources_controller.rb class Avo::BaseResourcesController < Avo::ResourcesController include AuthenticationController::Authentication before_action :is_logged_in? end # app/controllers/avo/posts_controller.rb class Avo::PostsController < Avo::BaseResourcesController end ``` :::warning You can't use `Avo::BaseController` and `Avo::ResourcesController` as **your base controller**. They are defined inside Avo. ::: When you generate a new resource or controller in Avo, it won't automatically inherit from the `Avo::BaseResourcesController`. However, you have two approaches to ensure that the new generated controllers inherit from a custom controller: ### `--parent-controller` option on the generators Both the `avo:controller` and `avo:resource` generators accept the `--parent-controller` option, which allows you to specify the controller from which the new controller should inherit. Here are examples of how to use it: ```bash rails g avo:controller city --parent-controller Avo::BaseResourcesController rails g avo:resource city --parent-controller Avo::BaseResourcesController ``` ### `resource_parent_controller` configuration option You can configure the `resource_parent_controller` option in the `avo.rb` initializer. This option will be used to establish the inherited controller if the `--parent-controller` argument is not passed on the generators. Here's how you can do it: ```ruby # config/initializers/avo.rb Avo.configure do |config| config.resource_parent_controller = "Avo::BaseResourcesController" # "Avo::ResourcesController" is default value end ``` ### Attach concerns to `Avo::BaseController` Alternatively you can use [this guide](https://avohq.io/blog/safely-extend-a-ruby-on-rails-controller) to attach methods, actions, and hooks to the main `Avo::BaseController` or `Avo::ApplicationController`. ## Manually registering resources In order to have a more straightforward experience when getting started with Avo, we are eager-loading the `app/avo/resources` directory. That makes all those resources available to your app without you doing anything else. If you want to manually load them use the `config.resources` option. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.resources = [ "Avo::Resources::User", "Avo::Resources::Fish", ] end ``` This tells Avo which resources you use and stops the eager-loading process on boot-time. This means that other resources that are not declared in this array will not show up in your app. ## Extending `Avo::BaseResource` You can customize `Avo::BaseResource` by creating your own version in your application. This custom resource can include methods and logic that you want all your resources to inherit. Here's an example to illustrate how you can do this: ```ruby # app/avo/base_resource.rb module Avo class BaseResource < Avo::Resources::Base # Example custom method: make all number fields cast their values to float def field(id, **args, &block) if args[:as] == :number args[:format_using] = -> { value.to_f } end super(id, **args, &block) end end end ``` All your resources will now inherit from your custom `Avo::BaseResource`, allowing you to add common functionality across your admin interface. For instance, the above example ensures that all number fields in your resources will have their values cast to floats. Your resource files will still look the same as they did before. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource # Your existing configuration for the Post resource end ``` ## Modify controls placement and appearance Configure where row controls appear on the view β€” placement, floating behavior, and hover visibility β€” using `row_controls_config`. See row controls configuration on table view. ## Cards Use the `def cards` method to add some cards to your resource. Check cards documentation for more details. ```ruby{9-19} class Avo::Resources::User < Avo::BaseResource def fields field :id, as: :id field :name, as: :text field :email, as: :text field :roles, as: :boolean_group, options: {admin: "Administrator", manager: "Manager", writer: "Writer"} end def cards card Avo::Cards::ExampleAreaChart, cols: 3 card Avo::Cards::ExampleMetric, cols: 2 card Avo::Cards::ExampleMetric, label: "Active users metric", description: "Count of the active users.", arguments: { active_users: true }, visible: -> { !resource.view.form? } end end ``` Cards on resources - Avo for Rails --- # Array Resource An Array Resource is a resource backed by in-memory data instead of a database table. Use it to let Avo display and manage structured data that doesn't come from a model. The `records` method can return an array of hashes, an array of Active Record objects, an `ActiveRecord::Relation`, or an array of `StoreModel` instances. Generate one with the `--array` flag: ```bash bin/rails generate avo:resource Movie --array ``` The generated class extends `Avo::Resources::ArrayResource`. Return the data from the `records` method and describe it with fields, like any other resource: ```ruby # app/avo/resources/movie.rb class Avo::Resources::Movie < Avo::Resources::ArrayResource def records [ { id: 1, name: "The Shawshank Redemption", release_date: "1994-09-23" }, { id: 2, name: "The Godfather", release_date: "1972-03-24", fun_fact: "The iconic cat in the opening scene was a stray found by director Francis Ford Coppola on the studio lot." }, { id: 3, name: "Pulp Fiction", release_date: "1994-10-14" } ] end def fields main_panel do field :id, as: :id field :name, as: :text field :release_date, as: :date field :fun_fact, only_on: :index, visible: -> { resource.record.fun_fact.present? } do record.fun_fact.truncate_words(10) end sidebar do field :fun_fact do record.fun_fact || "There is no register of a fun fact for #{record.name}" end end end end end ``` Each hash becomes a record, so `field :name` reads the `name:` key and computed fields can call `record.fun_fact` directly. If you return Active Record objects or a relation instead, Avo uses their real model class and fields behave as they do on a regular resource. Pagination works out of the box. ## Render it inside another resource The Array Resource pairs with the `Array` field to display array data on another resource β€” `field :attendees, as: :array` on a `Course`, for example. When rendered through the field, `records` is the last fallback in the data-fetching hierarchy; the field's block and the model's method take precedence. See the Array field documentation for the full hierarchy. :::warning Limitations - Sorting is not supported. - Large datasets can be slow β€” the array is rebuilt on every request. If that becomes a bottleneck, cache the data inside `records`. ::: :::info Heavier workloads If your data comes from an external API or the array approach starts to feel limiting, consider an HTTP Resource instead β€” it's backed by an endpoint and built for that kind of work. ::: --- # HTTP Resource An **HTTP Resource** is a resource backed by an HTTP endpoint instead of an Active Record model. Point it at an external API and Avo will list, show, create, update, and delete records through that API β€” no database table required. ```ruby # app/avo/resources/author.rb class Avo::Resources::Author < Avo::Core::Resources::Http self.http_adapter = { endpoint: "https://api.openalex.org/authors", parse_collection: -> { response["results"] }, parse_count: -> { response["meta"]["count"] } } def fields field :id, as: :id field :display_name end end ``` With only `endpoint` configured, Avo assumes the response body *is* the collection (or record) and reads the total count from `response["total"]`. The parsing options exist to adapt APIs that wrap their payloads differently. ## Install the gem Add `avo-http_resource` to your Gemfile: ```ruby # Gemfile gem "avo-http_resource", source: "https://packager.dev/avo-hq/" ``` Then run `bundle install`. The HTTP Resource becomes available as a new resource type. ## Generate a resource Pass the `--http` flag to the resource generator: ```bash bin/rails generate avo:resource Author --http ``` ## Parse the API response Most APIs wrap their data β€” a `results` key for the collection, a `meta` object for counts. Configure the three parsing procs to tell Avo where things live: - `parse_collection` returns the array of records from the index response. - `parse_record` returns a single record from the show response. - `parse_count` returns the total number of records, used for pagination. ```ruby # app/avo/resources/author.rb self.http_adapter = { endpoint: "https://api.openalex.org/authors", parse_collection: -> { response["results"] }, parse_record: -> { response }, parse_count: -> { response["meta"]["count"] } } ``` Each proc runs in an `Avo::ExecutionContext` with access to `raw_response` (the `HTTParty::Response`), `response` (the parsed body), and `headers` (the response headers), so you can dig into nested structures or inspect status codes as needed. :::info Pagination Avo pages the API for you β€” index requests always include `page` and `per_page` query parameters. ::: ## Send authentication headers Provide `headers` to send credentials or any other header with every request: ```ruby # app/avo/resources/author.rb self.http_adapter = { endpoint: "https://api.openalex.org/authors", headers: { "Authorization" => "Bearer #{ENV.fetch("API_KEY")}" } } ``` If the headers must be computed at request time β€” rotating tokens, per-user credentials β€” pass a proc returning the hash instead. ## Map sorting and filtering to query params If you want Avo's sorting UI (or any UI state) forwarded to the API, build the query string with `query_params`. The proc has access to controller `params`, and its result is merged into the request's query string: ```ruby # app/avo/resources/author.rb self.http_adapter = { endpoint: "https://api.openalex.org/authors", query_params: -> { if params[:sort_by].present? && params[:sort_direction].present? { sort: "#{params[:sort_by]}:#{params[:sort_direction]}" } else {} end } } ``` ## Customize the backing model Avo generates an `ActiveModel` class for each HTTP Resource behind the scenes. Use `model_class_eval` to define extra behavior on it β€” for example, obfuscating the ID used in URLs: ```ruby # app/avo/resources/author.rb self.http_adapter = { endpoint: "https://api.openalex.org/authors", model_class_eval: -> { define_method :to_param do Base64.encode64(id) end } } ``` ## Handle API errors When the API returns an error, raise `Avo::HttpError` inside any parsing proc: ```ruby parse_collection: -> { raise Avo::HttpError.new response["message"] if response["error"].present? response["results"] } ``` The controller rescues the exception and displays the message as a flash error in the UI, so users get meaningful feedback instead of a broken page. ## Customize create, update, and destroy Out of the box, the HTTP controller persists changes through the resource's client β€” `POST` to the endpoint on create, `PATCH` to `endpoint/:id` on update, and `DELETE` to `endpoint/:id` on destroy. The default implementation looks like this: ```ruby def save_record # Perform either a create or update request based on the current controller action @response = @resource.client.send(action_name, @record) # Should return true if the operation succeeded, false otherwise @response.success? end def destroy_model # Perform a DELETE request to remove the record via the external API @response = @resource.client.delete(@record.id) end ``` If your API needs different paths, extra parameters, or conditional logic, override these methods in the resource's controller: - `save_record` must return a **boolean** indicating whether the operation succeeded. - Inspect `action_name` (`"create"` or `"update"`) to tell the two operations apart. ```ruby # app/controllers/avo/authors_controller.rb class Avo::AuthorsController < Avo::Core::Controllers::Http def save_record auth_headers = { "Authorization" => "Bearer #{ENV.fetch("API_KEY")}" } response = if action_name == "create" MyCustomApi.post("/authors", body: @record.as_json, headers: auth_headers) else MyCustomApi.patch("/authors/#{@record.id}", body: @record.as_json, headers: auth_headers) end response.success? end end ``` ## Debug console HTTP Resources ship with an interactive debug console for inspecting exactly what your resource sends and receives. Visit `/http-resource/debug` (e.g. `/avo/http-resource/debug`), pick a resource and an action (`index`, `show`, `count`, `create`, `update`, or `delete`), and fire the request. For each run you can inspect: - the sent URL, query params, and (masked) request headers - the raw response alongside the parsed result - the request timing - the output of your `parse_collection`, `parse_record`, and `parse_count` blocks Errors are surfaced inline per stage β€” a failed request and a failing `parse_*` block are reported separately β€” so a broken adapter never crashes the page. The console is gated behind the `avo-http_resource` license feature and Avo's developer/admin access, the same gate as Avo's own debug tools. On top of that, it only lists β€” and only runs against β€” resources the current user is authorized to access, honoring each resource's authorization policy. If you use Avo's authorization, a user can never reach a resource from the console that their policy would otherwise hide; if you don't configure authorization, only the developer/admin gate applies. :::warning The `create`, `update`, and `delete` actions hit your real external API. From the console they require an explicit confirmation before running. ::: ### Add it to the sidebar To surface the console as a tool in the sidebar's **Tools** section, add a sidebar item partial to your app at `app/views/avo/sidebar/items/_http_resource_debugger.html.erb`: ```erb <%= render Avo::Sidebar::LinkComponent.new( label: "HTTP debugger", path: File.join(avo.root_path, "http-resource", "debug"), icon: "tabler/outline/api" ) %> ``` ### JSON API The same diagnostics are available as JSON for scripting or agent use. `POST` to `/http-resource/debug/run.json` with the params `resource`, `probe_action`, `id`, `page`, `limit`, `query`, `body`, and `confirm`. Write actions (`create`, `update`, `delete`) require `confirm=1` since they hit the real external API. ## Full example Every adapter option at once, against the [OpenAlex](https://openalex.org) API: ```ruby # app/avo/resources/author.rb class Avo::Resources::Author < Avo::Core::Resources::Http self.http_adapter = { endpoint: "https://api.openalex.org/authors", parse_collection: -> { raise Avo::HttpError.new response["message"] if response["error"].present? response["results"] }, parse_record: -> { raise Avo::HttpError.new response["message"] if response["error"].present? response }, parse_count: -> { response["meta"]["count"] }, model_class_eval: -> { define_method :to_param do Base64.encode64(id) end }, headers: { "Authorization" => "Bearer #{ENV.fetch("API_KEY")}" }, query_params: -> { if params[:sort_by].present? && params[:sort_direction].present? { sort: "#{params[:sort_by]}:#{params[:sort_direction]}" } else {} end } } def fields field :id, as: :id field :display_name field :cited_by_count, name: "Total citations" field :works_count, name: "Total works" end end ``` --- # Scopes Scopes bar Sometimes you need to segment your data beyond just a few filters. You might have a `User` resource but frequently need to see all the **Active users** or **Admin users**. You can use a filter for that, or add a scope β€” a one-click segment rendered as a tab bar above the records. ## Generate a scope ```bash bin/rails generate avo:scope admins ``` The generator creates a scope class in `app/avo/scopes`. Point its `scope` option to a scope on your model: ```ruby # app/avo/scopes/admins.rb class Avo::Scopes::Admins < Avo::Scopes::BaseScope self.name = "Admins" # Name displayed on the scopes bar self.description = "Admins only" # This is the tooltip value self.scope = :admins # A scope on the model this resource uses self.visible = -> { true } # Control the visibility end # app/models/user.rb class User < ApplicationRecord scope :admins, -> { where role: :admin } # This is used in the scope file above end ``` If you'd rather not define a model scope, `scope` also accepts a proc that modifies the query directly. ## Register the scope on a resource Because scopes are reusable, you must manually add each scope to a resource using the `scope` method inside the `scopes` method: ```ruby{4} # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def scopes scope Avo::Scopes::Admins end end ``` ### Set a default scope Pass `default: true` when registering a scope to apply it when you navigate to the resource's view. It also accepts a proc, so you can pick the default per user: ```ruby{5-6} # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def scopes scope Avo::Scopes::OddId # EvenId is the default scope only for admins scope Avo::Scopes::EvenId, default: -> { current_user.admin? } end end ``` ### Remove the "All" scope Avo adds an `All` scope by default. If you don't want it β€” or you'd rather ship a custom "All" scope of your own β€” call `remove_scope_all` inside the `scopes` method and mark another scope as the default: ```ruby{4-5} # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def scopes remove_scope_all scope Avo::Scopes::Everybody, default: true scope Avo::Scopes::Admins end end ``` ## Show record counts To display a count badge next to a scope's label, set `counter` on the scope class: ```ruby{3} # app/avo/scopes/active.rb class Avo::Scopes::Active < Avo::Scopes::BaseScope self.counter = :lazy end ``` Use `:lazy` on large tables so the count loads after the page paints instead of slowing down the request, or `:hover` to load it only when the user hovers over the scope tab; `true` (or `:eager`) computes it inline. For finer control β€” a custom count, showing the badge conditionally, or formatting the number β€” pass a Hash with `count`, `visible`, and `format` keys. The badge isn't limited to numbers: return a String (text or an emoji) from `count` and pass it through with a `format` of just `value`. ```ruby{3-7} # app/avo/scopes/active.rb class Avo::Scopes::Active < Avo::Scopes::BaseScope self.counter = { loading: :lazy, count: -> { query.active.count }, format: -> { "#{value} #{resource.name.pluralize.downcase}" } } end ``` ## Control who sees a scope Use the `visible` option to show, hide, or authorize a scope per user: ```ruby{4} # app/avo/scopes/even_id.rb class Avo::Scopes::EvenId < Avo::Scopes::BaseScope # Only show this scope to admins self.visible = -> { current_user.admin? } end ``` ## Dynamic values Every option accepts a proc instead of a static value, executed using the Avo::ExecutionContext with access to `query`, `resource`, `scope`, and `scoped_query`. For example, a description that adapts to the resource: ```ruby{3-5} # app/avo/scopes/even_id.rb class Avo::Scopes::EvenId < Avo::Scopes::BaseScope self.description = -> { "Only #{resource.name.downcase.pluralize} that have an even ID" } end ``` :::warning Performance note `scoped_query` executes the scope when called. If the scope is slow, using it inside a proc impacts every page load. To show record counts, prefer the built-in `counter` option over computing them in `name`. ::: See the execution context reference for what each option's proc has access to. ## Localization The shortest path is a locale key. Avo resolves `avo.scope_translations..{name,description}` before falling back to the class attribute, the same way it does for resources and actions: ```yaml # config/locales/avo.sv.yml sv: avo: scope_translations: admins: name: AdministratΓΆrer description: Endast administratΓΆrer ``` ```ruby # app/avo/scopes/admins.rb class Avo::Scopes::Admins < Avo::Scopes::BaseScope self.name = "Admins" # the fallback when no translation is present self.scope = :admins # Optional. Defaults to avo.scope_translations.admins # self.translation_key = "avo.scope_translations.admins" end ``` A namespaced scope uses the slash-joined path β€” `Avo::Scopes::Admin::Archived` resolves to `avo.scope_translations.admin/archived`. ### Or assign a callable The class attribute stays available and still resolves per request, which is what you want when the label depends on something the locale file cannot know: A scope's `name` and `description` accept a callable, and it is resolved on **every render** in the requesting user's locale. That is all it takes to translate a tab bar: assign a lambda that calls `I18n.t`. ```ruby{3-4} # app/avo/scopes/admins.rb class Avo::Scopes::Admins < Avo::Scopes::BaseScope self.name = -> { I18n.t("avo.scopes.admins.name", default: "Admins") } self.description = -> { I18n.t("avo.scopes.admins.description", default: "Admins only") } self.scope = :admins end ``` ```yaml # config/locales/avo.scopes.sv.yml sv: avo: scopes: admins: name: AdministratΓΆrer description: Endast administratΓΆrer ``` The key path is spelled in full above on purpose: the lookups are `avo.scopes.admins.name` and `avo.scopes.admins.description`. :::info The namespace is yours Pick a key namespace your app owns. `avo.scopes.*` reads well beside Avo's own keys and is safe β€” the Scopes gem itself only ships `avo.scopes.record_count` β€” but nothing requires it. Any namespace works. ::: :::warning Always pass a String `default:` Under a locale your app has not translated, a lookup with no `default:` renders `Translation missing: …` as the tab label, and Avo does not fall back to English for you unless you have configured `config.i18n.fallbacks`. ::: A Symbol is **not** resolved as an i18n key. `self.name = :admins` sets the literal label `:admins` β€” use a callable. ### Translating a label cannot break scope selection A scope's URL slug comes from its class path through `self.param`, never from `name`. So `?scope=admins` is identical in every locale, and a bookmark made in one language keeps working in another. You can translate `name` freely. ### The cost of a callable Because it re-resolves per request β€” which is exactly what makes it follow the locale β€” anything expensive inside `name` runs on every page load. An `I18n.t` lookup is cheap. A query is not: to show a record count, use the `counter` option rather than computing it in `name`. ### Translating the gem's own chrome Separately from your tab labels, the Scopes gem renders one string of its own β€” the accessible name on a [count pill](#show-record-counts) β€” and it ships **English only**: ```yaml # config/locales/avo.scopes.sv.yml sv: avo: scopes: record_count: one: "%{count} record" other: "%{count} records" ``` **Keep the `%{count}` placeholder.** The `All` tab's label is core's `avo.default_scope`, which is already translated in every locale Avo ships β€” override it there, not under `avo.scopes`. ## Limit index columns per scope A scope normally changes which **records** appear on the index. It can also change which **columns** appear while it's active β€” only on the view; the show, new, and edit views keep the resource's normal fields. There are three ways to do it, in order of precedence: - Define a `fields` method on the scope to declare the exact index columns from scratch, using the same DSL as a resource's `fields`. - Set `field_whitelist` to keep the resource's fields but show **only** the listed ids. - Set `field_blacklist` to keep the resource's fields but **hide** the listed ids. ```ruby{5-9} # app/avo/scopes/published.rb class Avo::Scopes::Published < Avo::Scopes::BaseScope self.scope = -> { query.where(published: true) } def fields field :id, as: :id field :title, as: :text field :published_at, as: :date_time end end ``` :::warning Display only `field_whitelist` and `field_blacklist` only change which columns render on the index. They are **not** an authorization boundary β€” the data is still loaded and stays visible on the show and edit views and through the API. To actually restrict access, use a policy or a field's `visible` option. ::: ## Full example ```ruby # app/avo/scopes/even_id.rb class Avo::Scopes::EvenId < Avo::Scopes::BaseScope self.name = "Even" # This will compute the description based on the resource name self.description = -> { "Only #{resource.name.downcase.pluralize} that have an even ID" } # This will scope the query to only even IDs self.scope = -> { query.where("#{resource.model_key}.id % 2 = ?", "0") } # Only show this scope to admins self.visible = -> { current_user.admin? } # Show a record count badge, loaded lazily so it won't slow down the page self.counter = :lazy end ``` --- # Record reordering Record reordering lets your users arrange records in a specific order β€” like reordering `Slide`s inside a `Carousel` or `MenuItem`s inside a `Menu` β€” using ordering buttons or drag and drop on the view. Configure it through the `ordering` class attribute on the resource. The `actions` are plain lambdas, so they pair naturally with an ordering gem like [`acts_as_list`](https://github.com/brendon/acts_as_list) or with your own positioning logic. ```ruby # app/avo/resources/course_link.rb class Avo::Resources::CourseLink < Avo::BaseResource self.ordering = { visible_on: :index, actions: { higher: -> { record.move_higher }, lower: -> { record.move_lower }, to_top: -> { record.move_to_top }, to_bottom: -> { record.move_to_bottom }, } } end ``` Without any `ordering` configuration, no reordering controls are rendered. Note that `visible_on` has no implicit default β€” if you omit it, the buttons won't show anywhere, so always set it to `:index`, `:association`, or both. ## Set up reordering with `acts_as_list` Install and configure the gem as instructed in its [readme](https://github.com/brendon/acts_as_list#example). Please ensure you [give all records position attribute values](https://github.com/brendon/acts_as_list#adding-acts_as_list-to-an-existing-model), so the gem works fine. Then add the configuration from the snippet above. The `record` inside each action is the instantiated model, and `move_higher`, `move_lower`, `move_to_top`, and `move_to_bottom` are provided by `acts_as_list`. If you're not using that gem, put your own position-changing logic inside the lambdas β€” each one also has access to `resource`, `options`, and `params`; the full contract is in the `actions` reference. That configuration generates a button with a popover containing the ordering buttons. Avo ordering ## Always show the order buttons If the resource you're updating requires reordering often, keep the buttons visible at all times with `display_inline: true`. ```ruby # app/avo/resources/course_link.rb class Avo::Resources::CourseLink < Avo::BaseResource self.ordering = { display_inline: true, visible_on: :index, actions: { higher: -> { record.move_higher }, lower: -> { record.move_lower }, to_top: -> { record.move_to_top }, to_bottom: -> { record.move_to_bottom }, } } end ``` Avo ordering ## Choose where the buttons appear `visible_on` controls which views render the reordering controls. If you want them on the resource's view, use `:index`: ```ruby{4} # app/avo/resources/course_link.rb class Avo::Resources::CourseLink < Avo::BaseResource self.ordering = { visible_on: :index, } end ``` If the order only makes sense in the scope of a parent record β€” like ordering `MenuItem`s for a `Menu` or `Slide`s for a `Carousel` β€” use `:association` so the buttons show up only in the `has_many` association view: ```ruby{4} # app/avo/resources/course_link.rb class Avo::Resources::CourseLink < Avo::BaseResource self.ordering = { visible_on: :association, } end ``` Use `[:index, :association]` to show them in both places. ## Sort the `Index` view by position Naturally, you'll want the view to list records in their position order. You may do that in two ways: 1. Add a `default_scope` to your model. If you're only using this ordering scheme in Avo, this is not the recommended way β€” it applies the scope to every query for that model, which you probably don't want. 2. Use `index_query` to alter the query only in Avo. ```ruby{3-5} # app/avo/resources/course_link.rb class Avo::Resources::CourseLink < Avo::BaseResource self.index_query = -> { query.order(position: :asc) } self.ordering = { display_inline: true, visible_on: :index, actions: { higher: -> { record.move_higher }, lower: -> { record.move_lower }, to_top: -> { record.move_to_top }, to_bottom: -> { record.move_to_bottom } } } end ``` ## Reorder using drag and drop Sometimes it's easier to just pick up a record and drop it where you'd like it to be. That's exactly what this feature does. It's disabled by default. To enable it, add `drag_and_drop: true` and an `insert_at` action to the `ordering` hash β€” both are required for the drag handles to appear. ```ruby{4,10} # app/avo/resources/course_link.rb class Avo::Resources::CourseLink < Avo::BaseResource self.ordering = { drag_and_drop: true, display_inline: true, visible_on: [:index, :association], actions: { higher: -> { record.move_higher }, lower: -> { record.move_lower }, to_top: -> { record.move_to_top }, to_bottom: -> { record.move_to_bottom }, insert_at: -> { record.insert_at position } } } end ``` Inside `insert_at`, the `position` local is the target position (an `Integer`) computed from where the record was dropped. ### Custom `position` attribute To compute the drop target, Avo reads the current position of the first record in the list β€” by default via `record.position`, which is what `acts_as_list` provides. If your model exposes its position under a different name, point the `position` option at it. ```ruby{4} # app/avo/resources/course_link.rb class Avo::Resources::CourseLink < Avo::BaseResource self.ordering = { position: -> { record.position_in_list }, drag_and_drop: true, display_inline: true, visible_on: [:index, :association], actions: { higher: -> { record.move_higher }, lower: -> { record.move_lower }, to_top: -> { record.move_to_top }, to_bottom: -> { record.move_to_bottom }, insert_at: -> { record.insert_at position } } } end ``` ## Authorization If you're using the authorization feature, please ensure you give the proper permissions using the `reorder?` policy method. ```ruby # app/policies/course_link_policy.rb class CourseLinkPolicy < ApplicationPolicy def reorder? = edit? # or a custom permission def reorder? user.can_reorder_items? end # other policy methods end ``` --- # Discreet Information Sometimes you need some information available on the record page, but not necessarily front-and-center. The `discreet_information` resource option renders one or more small items β€” icons with tooltips, badges, links, or key-value pairs β€” next to the record title, without using up a whole field. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.discreet_information = [ :timestamps, { as: :badge, text: -> { record.published_at ? "Published" : "Draft" }, icon: -> { "heroicons/outline/#{record.published_at ? "eye" : "eye-slash"}" } } ] # fields and other resource configuration end ``` By default the option is unset and nothing is rendered. You can pass a single item or an array of items; each item is either a preconfigured symbol or a `Hash` describing a custom item. ## Show the record's ID Use the `:id` preconfigured item to display the current record's ID as a key-value pair, saving a field slot. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.discreet_information = :id end ``` ## Show timestamps without using a field The reason we built this feature was that we wanted a place to display the `created_at` and `updated_at` timestamps without dedicating a whole field to them. Set the option to `:timestamps` and an icon is added next to the title. Hovering over it reveals both timestamps in a tooltip. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.discreet_information = :timestamps end ``` If the record has neither a `created_at` nor an `updated_at` value, the item is omitted. If you'd rather show a single timestamp as a visible key-value pair instead of a tooltip, use `:created_at` or `:updated_at`. ## Add a custom item Pass a `Hash` to take full control of an item. Pick the representation with `as` β€” `:text` (default), `:icon`, `:badge`, or `:key_value` β€” then set the options that type uses: `text` for text and badge items, `icon`, `title` for the tooltip, or `key` and `value` for key-value items. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.discreet_information = { as: :badge, text: -> { record.published_at ? "Published" : "Draft" }, title: -> { "Product is #{record.published_at ? "published" : "draft"}" }, icon: "tabler/outline/bulb" } end ``` Every option accepts a static value or a block. Blocks run in an `ExecutionContext`, so you have access to `record`, `resource`, and the rest of the common objects. ## Turn an item into a link If you want an item to navigate somewhere, add a `url`. Use `target` to control where it opens and `data` to set data attributes on the item. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.discreet_information = { as: :badge, text: "View on site", url: -> { main_app.post_path record }, target: :_blank } end ``` ## Show an item conditionally Set `visible` to a boolean or a block if an item should only appear in certain conditions. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.discreet_information = { as: :badge, text: "Draft", visible: -> { record.published_at.nil? } } end ``` ## Full example Here's a possible full configuration for the discreet information area, mixing preconfigured and custom items. ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.discreet_information = [ :id, :timestamps, :created_at, :updated_at, { as: :badge, text: "label", title: -> { sanitize("View #{record.name} on site", tags: %w[strong]) }, icon: -> { "heroicons/outline/arrow-top-right-on-square" }, url: -> { main_app.root_url }, target: :_blank }, { as: :text, text: -> { "Simple text #{record.id}" }, title: -> { sanitize("View #{record.name} on site", tags: %w[strong]) }, icon: -> { "tabler/outline/external-link" }, url: -> { main_app.root_url }, visible: true }, { as: :badge, text: "Test", visible: false }, { as: :key_value, key: "Key", value: "Value" }, { as: :icon, icon: "tabler/outline/cube-3d-sphere", title: -> { Time.now } } ] # fields and other resource configuration end ``` --- # Custom controls Avo displays a default set of buttons (controls) on the , , and views and at the end of each table row. Custom controls let you take over any of those areas β€” relabel or remove the default buttons and add your own links, action buttons, and dropdowns. An Avo Show page panel with the record title and customizable controls highlighted in the header tools area, above a card listing the record fields. Take over an area by assigning a block to the matching resource class attribute β€” `show_controls`, `edit_controls`, `index_controls`, or `row_controls`: ```ruby # app/avo/resources/fish.rb class Avo::Resources::Fish < Avo::BaseResource self.show_controls = -> do link_to "View on site", "https://fish.com", target: :_blank default_controls end end ``` Any area you don't customize keeps its default controls: The default show controls bar on an Avo resource show page header: a back button, a delete button, and an edit button. Inside a block you can mix the built-in controls (like `back_button` or `actions_list`) with your own `link_to` links and `action` buttons. See the API reference for every control and the options each one accepts. ## Customize the show controls On the view the default configuration is `back_button`, `delete_button`, `detach_button`, `actions_list`, and `edit_button`. Assign a `show_controls` block and declare the controls you want, in the order you want them: ```ruby # app/avo/resources/fish.rb class Avo::Resources::Fish < Avo::BaseResource self.show_controls = -> do back_button label: "", title: "Go back now" link_to "Fish.com", "https://fish.com", icon: "heroicons/outline/academic-cap", target: :_blank link_to "Turbo demo", "/admin/resources/fish/#{params[:id]}?change_to=new-content", class: "custom-class", data: { turbo_frame: "fish_custom_action_demo" } delete_button label: "", title: "Delete this fish" detach_button label: "", title: "Detach this fish" actions_list label: "Runnables", exclude: [Avo::Actions::ReleaseFish], style: :primary, color: :slate action Avo::Actions::ReleaseFish, style: :primary, color: :fuchsia, icon: "heroicons/outline/globe" edit_button label: "" end end ``` The customized show_controls bar on an Avo Fish show page header: a back button, Fish.com and Turbo demo links, a delete button, a fuchsia Release fish action, and an edit button. ## Customize the edit controls On the and views the default configuration is `back_button`, `delete_button`, `actions_list`, and `save_button`. Assign an `edit_controls` block β€” it applies to both views, so use `view` to differentiate when needed: ```ruby # app/avo/resources/fish.rb class Avo::Resources::Fish < Avo::BaseResource self.edit_controls = -> do back_button label: "", title: "Go back now" link_to "Fish.com", "https://fish.com", icon: "heroicons/outline/academic-cap", target: :_blank delete_button label: "", title: "Delete this fish" detach_button label: "", title: "Detach this fish" actions_list exclude: [Avo::Actions::ReleaseFish], style: :primary, color: :slate, label: "Runnables" action Avo::Actions::ReleaseFish, style: :primary, color: :fuchsia, icon: "heroicons/outline/globe" if view != :new save_button label: "Save Fish" end end ``` The customized edit_controls bar on an Avo Fish edit page header: a back button, a Fish.com link, a Runnables actions menu, a fuchsia Release fish action, and a Save Fish button. ## Customize the index controls On the view the default configuration is `attach_button`, `actions_list`, and `create_button`. Assign an `index_controls` block: ```ruby # app/avo/resources/fish.rb class Avo::Resources::Fish < Avo::BaseResource self.index_controls = -> do link_to "Fish.com", "https://fish.com", icon: "heroicons/outline/academic-cap", target: :_blank actions_list exclude: [Avo::Actions::DummyAction], style: :primary, color: :slate, label: "Runnables" if Fish.count > 0 action Avo::Actions::DummyAction, style: :primary, color: :fuchsia, icon: "heroicons/outline/globe" if Fish.count > 0 attach_button label: "Attach one Fish" create_button label: "Create a new and fresh Fish" end end ``` The customized index_controls bar on an Avo Fish index page header: a Fish.com link, a Runnables actions menu, a Release fish action, an Attach one Fish button, and a Create a new and fresh Fish button. ## Customize the row controls At the end of each table row on the view the default configuration is `order_controls`, `show_button`, `edit_button`, `detach_button`, and `delete_button`. Assign a `row_controls` block: ```ruby # app/avo/resources/fish.rb class Avo::Resources::Fish < Avo::BaseResource self.row_controls = -> do action Avo::Actions::ReleaseFish, label: "Release #{record.name}", style: :primary, color: :blue, icon: "heroicons/outline/hand-raised" unless params[:view_type] == "grid" edit_button title: "Edit this Fish now!" show_button title: "Show this Fish now!" delete_button title: "Delete this Fish now!", confirmation_message: "Are you sure you want to delete this Fish?" actions_list style: :primary, color: :slate, label: "Actions" unless params[:view_type] == "grid" action Avo::Actions::ReleaseFish, title: "Release #{record.name}", icon: "heroicons/outline/hand-raised", style: :icon link_to "Information about #{record.name}", "https://en.wikipedia.org/wiki/#{record.name}", icon: "heroicons/outline/information-circle", target: :_blank, style: :icon end end ``` The same controls are displayed on the grid view items too β€” check `params[:view_type]` (as above) to show a control on only one of the two view types. The Avo Fish index table with several rows; the customized row controls area on one middle row is highlighted in red, showing a Release action button, edit, show and delete icons, an Actions menu, and icon links. ## Keep the default controls If you just want to add a link before or after the default controls, you don't have to re-declare them all β€” call `default_controls` where you want them to appear: ```ruby # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource self.show_controls = -> do # This link will be added before all other controls. link_to "View on site", post_path(record), target: :_blank default_controls end end ``` A show controls bar with a custom "View on site" link prepended before the default back, delete, and edit controls. ## What you can put in a control Beyond the built-in buttons, a control block accepts your own links and action buttons β€” and because a `link_to` forwards its `data`, `class`, and `target` to the rendered ``, you can drive Turbo and Stimulus straight from a control. ### Links `link_to` takes a label and any path β€” an external URL, a Rails path helper, a `mailto:`, whatever you need: ```ruby self.show_controls = -> do # External URL in a new tab link_to "Docs", "https://avohq.io/docs", icon: "heroicons/outline/book-open", target: :_blank # Rails path helper for the current record link_to "View on site", post_path(record), icon: "heroicons/outline/globe-alt" # mailto: link link_to "Email author", "mailto:#{record.author.email}", icon: "heroicons/outline/envelope" # Icon-only β€” the label becomes the tooltip link_to "Open in Stripe", "https://dashboard.stripe.com/customers/#{record.stripe_id}", icon: "heroicons/outline/credit-card", style: :icon default_controls end ``` ### Built-in controls Relabel, restyle, or reorder the built-in buttons β€” `back_button`, `edit_button`, `show_button`, `delete_button`, and the rest: ```ruby self.show_controls = -> do back_button label: "", title: "Back" # icon-only edit_button label: "Edit this fish" delete_button label: "", icon: "heroicons/outline/trash", confirmation_message: "Delete this fish for good?" end ``` ### Actions Pull an action out as its own button, pass it `arguments`, and keep the rest in the dropdown: ```ruby self.show_controls = -> do # A single action as a button action Avo::Actions::ReleaseFish, style: :primary, color: :fuchsia, icon: "heroicons/outline/globe" # Send data to the action action Avo::Actions::ExportSelection, arguments: { format: :csv } # Everything else stays in the dropdown actions_list exclude: [Avo::Actions::ReleaseFish], label: "More" default_controls end ``` ### Turbo triggers A `link_to` forwards its `data` hash to the ``, so any `data-turbo-*` attribute works. Load a response into a Turbo Frame, or turn a link into a `POST`/`DELETE` button: ```ruby self.show_controls = -> do # Render the response into a Turbo Frame already on the page link_to "Preview", preview_fish_path(record), icon: "heroicons/outline/eye", data: { turbo_frame: "fish_preview" } # A link that submits as a POST, with a confirmation dialog link_to "Archive", archive_fish_path(record), icon: "heroicons/outline/archive-box", data: { turbo_method: :post, turbo_confirm: "Archive this fish?" } default_controls end ``` ### JavaScript triggers The same `data` pass-through lets you wire a control to a Stimulus controller β€” set the `controller`, `action`, and any value attributes, and Avo renders them on the link: ```ruby self.show_controls = -> do link_to "Copy API key", "#", icon: "heroicons/outline/clipboard", data: { controller: "clipboard", action: "clipboard#copy", clipboard_text_value: record.api_key } default_controls end ``` Load the controller through your asset pipeline so Stimulus picks it up. ## Group links and actions in a dropdown If you have too many controls for the bar, group your custom links and actions in a `list` dropdown: ```ruby # app/avo/resources/fish.rb class Avo::Resources::Fish < Avo::BaseResource self.index_controls = -> do list label: "Custom Index List", icon: "heroicons/outline/cube-transparent", style: :primary, color: :slate, title: "A custom list" do link_to "Google", "https://google.com", icon: "heroicons/outline/academic-cap" action Avo::Actions::Sub::DummyAction, icon: "heroicons/outline/globe" link_to "Fish.com", "https://fish.com", icon: "heroicons/outline/fire", target: :_blank end end end ``` The custom `list` control open on an Avo Fish index page header: a "Custom Index List" dropdown button (highlighted) among the page controls, with its menu open showing Google and Fish.com links and a Dummy action, each with an icon. Within the `list` block you can use `link_to`, `action`, and `divider` β€” the built-in button controls are not allowed there. ## Conditionally show controls Actions have a `visible` block that controls their visibility, but a control declared with `action` ignores it. Because the controls declaration is a block, use regular `if`/`else` statements instead: ```ruby{6-8} # app/avo/resources/fish.rb class Avo::Resources::Fish < Avo::BaseResource self.show_controls = -> do back_button label: "", title: "Go back now" # visibility conditional if record.released? action Avo::Actions::ReleaseFish, style: :primary, color: :fuchsia, icon: "heroicons/outline/globe" end edit_button label: "" end end ``` :::info The exception is actions declared inside a `list` dropdown β€” those do respect the action's `visible` block. ::: --- # Actions Actions let you run custom operations on one or many records β€” or no records at all β€” straight from the UI. Deactivate a user, send a notification, export a CSV, trigger a background job: anything you can express in Ruby can become an entry in the **Actions** dropdown, optionally with a confirmation modal and a form that collects extra input before running. An action is a plain Ruby class with a `handle` method, registered on one or more resources: ```ruby # app/avo/actions/toggle_inactive.rb class Avo::Actions::ToggleInactive < Avo::BaseAction self.name = "Toggle Inactive" def handle(query:, **args) query.each do |record| record.update! inactive: !record.inactive end succeed "Toggled status for #{query.count} records" end end # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def actions action Avo::Actions::ToggleInactive end end ``` With no extra configuration the action shows up in the **Actions** dropdown on the and views (it's hidden on by default), asks "Are you sure you want to run this action?" in a confirmation modal, runs `handle` with the selected records, shows a green success notification, and reloads the page. ## What happens when an action runs 1. **Record selection.** On the view the user selects one or more records (bulk); on the view or row controls the current record is the target. [Standalone](#run-an-action-without-records) actions skip this phase. 2. **Trigger.** The user picks the action from the **Actions** dropdown or a custom control. 3. **Confirmation modal** (optional). A modal shows the action's message and any [fields](#collect-input-with-fields) to fill out. The user runs or cancels. Set `confirmation` to `false` to skip this phase and execute immediately. 4. **Execution.** Your [`handle`](#write-the-handle-method) method runs with the records, field values, current user, and request. You give [feedback](#give-feedback-to-the-user) and pick a [response](#control-what-happens-after-execution). ## Generate an action ```bash bin/rails generate avo:action toggle_inactive ``` This creates `app/avo/actions/toggle_inactive.rb` with a commented-out skeleton for `visible`, `fields`, and `handle`. Pass `--standalone` to generate an action that doesn't need selected records, and use a namespace to group related actions: ```bash # Standalone action bin/rails generate avo:action generate_monthly_report --standalone # Namespaced action -> app/avo/actions/admin/approve_user.rb bin/rails generate avo:action admin/approve_user ``` ## Register the action on a resource Declare the actions available on a resource inside its `actions` method: ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def actions # User status actions action Avo::Actions::ActivateUser action Avo::Actions::DeactivateUser divider label: "Communication" action Avo::Actions::SendWelcomeEmail, icon: "heroicons/outline/envelope" end end ``` Pass `icon` to show an icon next to the action in the dropdown, and use `divider` to separate actions into logical groups, with an optional label. If you want to send custom data to the action, pass `arguments` β€” a hash (or a proc that returns one) that becomes available everywhere in the action class: :::code-group ```ruby [app/avo/resources/user.rb] class Avo::Resources::User < Avo::BaseResource def actions action Avo::Actions::ToggleInactive, arguments: { special_message: true } # Or as a proc to make it dynamic action Avo::Actions::ToggleInactive, arguments: -> do { special_message: resource.view.index? && current_user.is_admin? } end end end ``` ```ruby [app/avo/actions/toggle_inactive.rb] class Avo::Actions::ToggleInactive < Avo::BaseAction def handle(**args) if arguments[:special_message] succeed "I love πŸ₯‘" else succeed "Success response ✌️" end end end ``` ::: :::warning When using Pundit policies, access to actions is restricted with the `act_on?` method. If you think you should see an action and you don't, check the policy. More info here. ::: :::info You may use the custom controls feature to show actions outside the dropdown, as standalone buttons. ::: ## Collect input with fields An action can define fields, shown to the user in the action's modal. They work the same way as fields on resources. When the action runs on a single record the fields are hydrated from that record; otherwise they're plain form inputs. The submitted values arrive in `handle` as the `fields` argument. ```ruby # app/avo/actions/toggle_inactive.rb class Avo::Actions::ToggleInactive < Avo::BaseAction def fields field :notify_user, as: :boolean field :message, as: :textarea end end ``` Check out the Fields page for everything fields can do. ## Write the `handle` method `handle` is where your business logic lives. It receives keyword arguments: - `query` β€” the selected record(s); a single record is wrapped in an array for consistency (`records` is an alias) - `fields` β€” the values submitted through the action's form fields - `current_user` β€” the currently authenticated user - `resource` β€” the Avo resource that triggered the action - `request` β€” the current `ActionDispatch::Request` ```ruby # app/avo/actions/toggle_inactive.rb class Avo::Actions::ToggleInactive < Avo::BaseAction self.name = "Toggle Inactive" def fields field :notify_user, as: :boolean field :message, as: :textarea end def handle(query:, fields:, current_user:, resource:, request:, **args) query.each do |record| record.update! inactive: !record.inactive record.notify(fields[:message]) if fields[:notify_user] end succeed "Successfully toggled status for #{query.count} records" end end ``` ## Give feedback to the user After the action runs, respond with one or more notifications: `succeed` (green), `inform` (blue), `warn` (orange), or `error` (red). With no explicit feedback, Avo shows an "Action ran successfully" info notification. ```ruby def handle(**args) succeed "Success response ✌️" warn "Warning response ✌️" inform "Info response ✌️" error "Error response ✌️" end ``` Avo action feedback notifications: success, info, warning, and error alerts stacked. Each method takes an optional `timeout` in milliseconds β€” or `:forever` to keep the notification open until the user dismisses it: ```ruby succeed "Task completed successfully", timeout: 5000 warn "Important warning - requires attention", timeout: :forever ``` If you don't want any notification β€” a redirect scenario, for example β€” use `silent`. ## Control what happens after execution Besides feedback, `handle` picks how the UI responds. The default is a full-page `reload`. You can instead: - `redirect_to` a different path - `download` a generated file - `keep_modal_open` to show errors while preserving the user's input - `close_modal` (alias `do_nothing`) to close the modal and leave the page as is - `reload_records` to refresh only the affected table rows or grid cards - `navigate_to_action` to chain into another action - `append_to_response` to add your own turbo stream responses See the API reference for each response's behavior and signature. ### Build a multi-step flow Because `navigate_to_action` can pass `arguments` along, you can chain actions into a wizard: the first action collects which fields to update, the second renders only those fields and performs the update. :::code-group ```ruby[PreUpdate] class Avo::Actions::City::PreUpdate < Avo::BaseAction self.name = "Update" def fields field :name, as: :boolean field :population, as: :boolean end def handle(query:, fields:, **args) navigate_to_action Avo::Actions::City::Update, arguments: { cities: query.map(&:id), render_name: fields[:name], render_population: fields[:population] } end end ``` ```ruby[Update] class Avo::Actions::City::Update < Avo::BaseAction self.name = "Update" self.visible = -> { false } def fields field :name, as: :text if arguments[:render_name] field :population, as: :number if arguments[:render_population] end def handle(fields:, **args) City.find(arguments[:cities]).each do |city| city.update! fields end succeed "City updated!" end end ``` ::: You can try this flow on the [avodemo](https://main.avodemo.com/avo/resources/cities): select a record, run the "Update" action, choose the fields, and update them in the follow-up action. ## Customize the modal The modal's texts can each be a string or a block β€” blocks run in `Avo::ExecutionContext` with access to `resource`, `record`, `view`, `arguments`, and `query`: - `name` β€” the action's display name in the dropdown and modal - `description` β€” a short explanation under the name in the modal header - `message` β€” the confirmation message - `confirm_button_label` / `cancel_button_label` β€” the button labels ```ruby # app/avo/actions/release_fish.rb class Avo::Actions::ReleaseFish < Avo::BaseAction self.name = "Release fish" self.description = "Release the fish back into the ocean" self.message = -> { record.present? ? "Are you sure you want to release #{record.name}?" : "Are you sure you want to release the fish?" } self.confirm_button_label = "Release fish" self.cancel_button_label = "Cancel release" end ``` If the action is safe to run without asking, set `confirmation` to `false` and it executes immediately on trigger. To stop the modal from closing when the user clicks outside it, set `close_modal_on_backdrop_click` to `false`. ## Run an action without records Standalone actions aren't tied to selected records β€” global reports, maintenance tasks, background jobs. They stay enabled in the **Actions** dropdown even with nothing selected and can live alongside regular actions on the same resource. Generate one with the `--standalone` flag, or set the attribute on an existing action: ```ruby # app/avo/actions/global_report.rb class Avo::Actions::GlobalReport < Avo::BaseAction self.name = "Generate Global Report" self.standalone = true end ``` :::tip Standalone actions pair well with [fields](#collect-input-with-fields) to collect the input the operation needs. ::: ## Control visibility and authorization By default actions show on every view except . Use `visible` to change where an action appears β€” for example, only on the view: ```ruby # app/avo/actions/global_report.rb class Avo::Actions::GlobalReport < Avo::BaseAction self.visible = -> { view.index? } end ``` To restrict *who* can run an action, use `authorize`. Unauthorized actions are hidden, and the controller re-evaluates the check on execution, so a bad actor can't run one by crafting a request: ```ruby class Avo::Actions::GlobalReport < Avo::BaseAction self.authorize = -> { current_user.is_admin? } end ``` Both accept a boolean or a block executed in `Avo::ExecutionContext`. ## Trigger an action from a link You may want a link somewhere in the UI β€” a field on the view, a dashboard card β€” that opens an action's modal directly. Call the action class's `link_arguments` method with a resource instance; it returns the `[path, data]` pair a proper action link needs: ```ruby # app/avo/resources/city.rb class Avo::Resources::City < Avo::BaseResource def fields field :name, as: :text, name: "Name (click to edit)", only_on: :index do path, data = Avo::Actions::City::Update.link_arguments( resource: resource, arguments: { cities: Array[resource.record.id], render_name: true } ) link_to resource.record.name, path, data: data end end end ``` An Avo Cities index where a record's name is a clickable link; clicking it opens an Update action modal with a prefilled name field, which when run updates the row. If you don't have an instantiated resource at hand β€” a custom partial on a dashboard, say β€” build one yourself, with or without a record: ```ruby path, data = Avo::Actions::City::Update.link_arguments( resource: Avo::Resources::City.new(record: city) ) link_to "Update city", path, data: data ``` The `arguments` are encrypted and Base64-encoded before being put in the URL, so it's safe to pass sensitive data. ## Read params from the page that triggered the action The action's modal is rendered in a new request, so query params present on the or page aren't directly available. To read them β€” for example to prefill a field β€” parse `request.referer`: ```ruby # app/avo/actions/dummy_action.rb class Avo::Actions::DummyAction < Avo::BaseAction def fields field :some_field, as: :hidden, default: -> { # Parse the URL of the page that triggered the action parent_params = URI.parse(request.referer).query .split("&") .map { |param| param.split("=") } .to_h .with_indifferent_access (parent_params[:hey] == "ya") ? :yes : :no } end end ``` With this in place, visiting `https://example.com/avo/resources/users?hey=ya` and running the action sets the field's default based on the `hey` param. ## StimulusJS Actions play well with custom Stimulus controllers. Follow the JavaScript guide for more information. --- # Select All The "Select All" feature is designed to enable users to select all queried records and perform actions on the entire selection. This feature is particularly useful when dealing with large datasets, allowing users to trigger actions on all queried records, not just the ones visible on the current page. ## How does it work? An Avo Projects index where checking the header Select all checkbox selects the page and offers a Select all matching link, which selects all 36 records across every page. When a user toggles the "Select all" checkbox, Avo will first check to see if there are more records than just those displayed on that page, and if there are, it will ask if the user if they want to select all the records or not. This is being done through serializing the query to be unserialized back in the action. ## Serializing the query The query might include various filters, sorting parameters, and other custom elements. Reconstructing this query at the time of the action request can be complex. Therefore, the system serializes the entire query object into a secure format before sending it with the action request. - **Security**: To ensure that sensitive data is protected, the serialized query is encrypted before it is transmitted. - **Efficiency**: This approach allows the system to accurately and efficiently reconstruct the original query when the action is executed, ensuring that all relevant records are included. :::warning If an error occurs during the serialization process, the "Select All" feature is automatically disabled. This safeguard ensures that the page will not crash because of a coding error. We listed a few reasons on why it might crash below. ::: ## Serialization known issues In this section, we outline common serialization problems and provide guidance on how to resolve them effectively. ##### `normalize` If your model includes any `normalize` proc, such as: ```ruby normalizes :status, with: ->(status) { status } ``` Serialization may fail when a filter is applied to the normalized attribute (e.g., `status` in this example). This can result in the error `TypeError: no _dump_data is defined for class Proc`, which causes the "Select All" feature to be automatically disabled. For applications created before Rails `7.1`, configuring the `marshalling_format_version` to `7.1` or higher will resolve the issue: ```ruby # config/application.rb config.active_record.marshalling_format_version = 7.1 ``` More details on [`normalizes` documentation](https://api.rubyonrails.org/classes/ActiveRecord/Normalization/ClassMethods.html#method-i-normalizes). --- # Cover and avatar It's common for a record to have a visual representation. A user might have a headshot, a company might have a logo, or a product might have an image. Avo can display these in two ways: - the **avatar** (`self.avatar`) β€” a small photo shown on the and views and in the breadcrumbs - the **cover** (`self.cover`) β€” a large banner image displayed at the top of the record Record avatar on the Show view and in the breadcrumbs ## Add an avatar Use the `avatar` option. It takes two arguments: [`visible_on`](#visible_on) and [`source`](#source). ```ruby # app/avo/resources/user.rb self.avatar = { visible_on: [:show, :forms], source: -> { if view.index? # We're on the index page and don't have a record to reference DEFAULT_IMAGE else # We have a record so we can reference its avatar record.avatar end } } ``` When no avatar is configured (or the source is blank), Avo falls back to the record's initials in places like the breadcrumbs. To also show the avatar as a column on the view, add the avatar field to the resource. ## Add a cover photo Use the `cover` option. It takes the same [`visible_on`](#visible_on) and [`source`](#source) arguments, plus a [`size`](#size). You can point it to an Active Storage field or a custom path. ```ruby # app/avo/resources/post.rb self.cover = { size: :md, # :sm, :md, :lg, or :full visible_on: [:show, :forms], # can be :show, :index, :edit, or a combination [:show, :index] source: -> { if view.index? # We're on the index page and don't have a record to reference DEFAULT_IMAGE else # We have a record so we can reference its cover photo record.cover_photo end } } ``` :::warning Renamed in Avo 4 These options were called `profile_photo` and `cover_photo` in Avo 3. See the upgrade guide. ::: ## Options Both `avatar` and `cover` accept `visible_on` and `source`. `size` applies only to `cover`. --- # Views The Avo CRUD feature generates four main views for each resource. ## Index The page where you see all your resources listed in a table, grid, or map.
Click row to view record Resource custom components ## Show The page where you see one resource in more detail.
Resource custom components ## Edit The page where you can edit one resource.
Resource custom components ## New The page where you can create a new resource.
Resource custom components ## View groups Three named groups let you target several views at once: ### Display `:display` is an alias for the `Index` and `Show` views where you can display records and their details. ### Form `:form` is an alias for the `Edit` and `New` views for creating and editing records. The `create` and `update` requests that submit those forms also count as form views. ### Single `:single` is an alias for every view except `Index` β€” that is `Show`, `Edit`, and `New` (plus the `create` and `update` form submissions). ## Preview The fields marked with `show_on: :preview` will be shown in the preview field popup. By default, all fields are hidden in `:preview`. ## Checking the current view The `view` object, available in the code, is an instance of the `Avo::ViewInquirer` class. This enables you to examine the existing `view` status through expressions such as `view.show?` and `view.index?`. Essentially, these are equivalent to asserting whether view equals `show` or `index`. ## Multiple ways to check ```ruby view == "edit" # Check against a string view == :edit # Check against a symbol view.edit? # Ask if it's a view view.form? # Ask if it's a collection of views view.in? [:edit, :new] # Check against an array of symbols view.in? ["edit", "new"] # Check against an array of strings ``` ::: code-group ```ruby [Ask] if view.show? # Code for the "show" view elsif view.index? # Code for the "index" view elsif view.edit? # Code for the "edit" view elsif view.new? # Code for the "new" view elsif view.form? # Code for the "new" or "edit" views elsif view.display? # Code for the "index" or "show" views end ``` ```ruby [Symbol comparator] if view == :show # Code for the "show" view elsif view == :index # Code for the "index" view elsif view == :edit # Code for the "edit" view elsif view == :new # Code for the "new" view end ``` ```ruby [String comparator] if view == "show" # Code for the "show" view elsif view == "index" # Code for the "index" view elsif view == "edit" # Code for the "edit" view elsif view == "new" # Code for the "new" view end ``` ::: It's also possible to check if the view is on a `form` (`new`, `edit`) or `display` (`index`, `show`). ::: code-group ```ruby [Ask] if view.form? # Code for the "new" and "edit" views elsif view.display? # Code for the "show" and "index" views end ``` ```ruby [Symbol comparator] if view.in? [:new, :edit] # Code for the "new" and "edit" views elsif view.in? [:show, :index] # Code for the "show" and "index" views end ``` ```ruby [String comparator] if view.in? ["new", "edit"] # Code for the "new" and "edit" views elsif view.in? ["show", "index"] # Code for the "show" and "index" views end ``` ::: ## Common `visible_on` configuration values In many parts of the DSL you'll be able to configure visibility through options like `visible_on`, `show_on`, `hide_on`, `only_on`, or `except_on`. These options control the views where the configuration applies. You may use the following values: - `:show` - `:edit` - `:new` - `:index` - `:forms` β€” expands to `:new` and `:edit` - `:display` β€” expands to `:index` and `:show` You may also use a combination of views using an array. - `[:show, :index]` - `[:show, :forms]` Field visibility options (`show_on`, `hide_on`, `only_on`, `except_on`) additionally accept `:preview` β€” see [Preview](#preview) above. ## View types The view can render records through several view types, each with its own options, documented on its own page: - Table view β€” the default tabular layout; row controls placement and per-row styling. - Grid view β€” card-based layout with cover, title, body, and badge, for image-heavy resources. - Map view β€” plot records with geospatial data on a map. - Custom view types β€” register entirely new view types from a plugin. ### Restrict the available view types By default, Avo displays all the configured view types on the view switcher. For example, if you have `map_view` and `grid_view` configured, both of them, along with the `table_view`, will be available on the view switcher. However, there might be cases where you only want to make a specific view type available without removing the configurations for other view types. This can be achieved using the `view_types` class attribute on the resource. Note that when only one view type is available, the view switcher will not be displayed. ```ruby{4} # app/avo/resources/city.rb class Avo::Resources::City < Avo::BaseResource # ... self.view_types = :table # ... end ``` If you want to make multiple view types available, you can use an array. The icons on the view switcher will follow the order in which they are declared in the configuration. ```ruby{4} # app/avo/resources/city.rb class Avo::Resources::City < Avo::BaseResource # ... self.view_types = [:table, :grid] # ... end ``` You can also dynamically restrict the view types based on user roles, params, or other business logic. To do this, assign a block to the `view_types` attribute. Within the block, you'll have access to `resource`, `record`, `params`, `current_user`, and other default accessors provided by `ExecutionContext`. ```ruby{4-10} # app/avo/resources/city.rb class Avo::Resources::City < Avo::BaseResource # ... self.view_types = -> do if current_user.is_admin? [:table, :grid] else :table end end # ... end ``` The current pick is persisted in the URL as the `view_type` query parameter, so it survives page reloads and can be bookmarked. :::warning Requesting a view type that isn't in the available list raises an error, and rendering a view type that was never registered raises `Avo::ViewTypeComponentNotFoundError`. Keep `view_types`, `default_view_type`, and your registered view types in sync. ::: --- # Views The Avo CRUD feature generates four main views for each resource. ## Index The page where you see all your resources listed in a table, grid, or map.
Click row to view record Resource custom components ## Show The page where you see one resource in more detail.
Resource custom components ## Edit The page where you can edit one resource.
Resource custom components ## New The page where you can create a new resource.
Resource custom components ## View groups Three named groups let you target several views at once: ### Display `:display` is an alias for the `Index` and `Show` views where you can display records and their details. ### Form `:form` is an alias for the `Edit` and `New` views for creating and editing records. The `create` and `update` requests that submit those forms also count as form views. ### Single `:single` is an alias for every view except `Index` β€” that is `Show`, `Edit`, and `New` (plus the `create` and `update` form submissions). ## Preview The fields marked with `show_on: :preview` will be shown in the preview field popup. By default, all fields are hidden in `:preview`. ## Checking the current view The `view` object, available in the code, is an instance of the `Avo::ViewInquirer` class. This enables you to examine the existing `view` status through expressions such as `view.show?` and `view.index?`. Essentially, these are equivalent to asserting whether view equals `show` or `index`. ## Multiple ways to check ```ruby view == "edit" # Check against a string view == :edit # Check against a symbol view.edit? # Ask if it's a view view.form? # Ask if it's a collection of views view.in? [:edit, :new] # Check against an array of symbols view.in? ["edit", "new"] # Check against an array of strings ``` ::: code-group ```ruby [Ask] if view.show? # Code for the "show" view elsif view.index? # Code for the "index" view elsif view.edit? # Code for the "edit" view elsif view.new? # Code for the "new" view elsif view.form? # Code for the "new" or "edit" views elsif view.display? # Code for the "index" or "show" views end ``` ```ruby [Symbol comparator] if view == :show # Code for the "show" view elsif view == :index # Code for the "index" view elsif view == :edit # Code for the "edit" view elsif view == :new # Code for the "new" view end ``` ```ruby [String comparator] if view == "show" # Code for the "show" view elsif view == "index" # Code for the "index" view elsif view == "edit" # Code for the "edit" view elsif view == "new" # Code for the "new" view end ``` ::: It's also possible to check if the view is on a `form` (`new`, `edit`) or `display` (`index`, `show`). ::: code-group ```ruby [Ask] if view.form? # Code for the "new" and "edit" views elsif view.display? # Code for the "show" and "index" views end ``` ```ruby [Symbol comparator] if view.in? [:new, :edit] # Code for the "new" and "edit" views elsif view.in? [:show, :index] # Code for the "show" and "index" views end ``` ```ruby [String comparator] if view.in? ["new", "edit"] # Code for the "new" and "edit" views elsif view.in? ["show", "index"] # Code for the "show" and "index" views end ``` ::: ## Common `visible_on` configuration values In many parts of the DSL you'll be able to configure visibility through options like `visible_on`, `show_on`, `hide_on`, `only_on`, or `except_on`. These options control the views where the configuration applies. You may use the following values: - `:show` - `:edit` - `:new` - `:index` - `:forms` β€” expands to `:new` and `:edit` - `:display` β€” expands to `:index` and `:show` You may also use a combination of views using an array. - `[:show, :index]` - `[:show, :forms]` Field visibility options (`show_on`, `hide_on`, `only_on`, `except_on`) additionally accept `:preview` β€” see [Preview](#preview) above. ## View types The view can render records through several view types, each with its own options, documented on its own page: - Table view β€” the default tabular layout; row controls placement and per-row styling. - Grid view β€” card-based layout with cover, title, body, and badge, for image-heavy resources. - Map view β€” plot records with geospatial data on a map. - Custom view types β€” register entirely new view types from a plugin. ### Restrict the available view types By default, Avo displays all the configured view types on the view switcher. For example, if you have `map_view` and `grid_view` configured, both of them, along with the `table_view`, will be available on the view switcher. However, there might be cases where you only want to make a specific view type available without removing the configurations for other view types. This can be achieved using the `view_types` class attribute on the resource. Note that when only one view type is available, the view switcher will not be displayed. ```ruby{4} # app/avo/resources/city.rb class Avo::Resources::City < Avo::BaseResource # ... self.view_types = :table # ... end ``` If you want to make multiple view types available, you can use an array. The icons on the view switcher will follow the order in which they are declared in the configuration. ```ruby{4} # app/avo/resources/city.rb class Avo::Resources::City < Avo::BaseResource # ... self.view_types = [:table, :grid] # ... end ``` You can also dynamically restrict the view types based on user roles, params, or other business logic. To do this, assign a block to the `view_types` attribute. Within the block, you'll have access to `resource`, `record`, `params`, `current_user`, and other default accessors provided by `ExecutionContext`. ```ruby{4-10} # app/avo/resources/city.rb class Avo::Resources::City < Avo::BaseResource # ... self.view_types = -> do if current_user.is_admin? [:table, :grid] else :table end end # ... end ``` The current pick is persisted in the URL as the `view_type` query parameter, so it survives page reloads and can be bookmarked. :::warning Requesting a view type that isn't in the available list raises an error, and rendering a view type that was never registered raises `Avo::ViewTypeComponentNotFoundError`. Keep `view_types`, `default_view_type`, and your registered view types in sync. ::: --- # Table view The table view is the default way to display resources in Avo. It provides a powerful, tabular layout that supports searching, sorting, filtering, and pagination out of the box. Table view With no configuration, every resource renders as a table with the row controls (show, edit, delete, actions) on the right side of each row and standard row styling. Two hooks let you customize it: [row controls placement](#row-controls) and [`row_options`](#style-rows-with-row_options) for per-row HTML attributes. ## Row controls By default, resource controls are positioned on the right side of record rows. However, if the table contains many columns, these controls may become obscured. In such cases, you may prefer to move the controls to the left side, float them over the row, or reveal them only on hover. ### Global configuration If you want to change the defaults for every resource, set `resource_row_controls_config` in the initializer: ```ruby # config/initializers/avo.rb Avo.configure do |config| config.resource_row_controls_config = { placement: :left, float: true, show_on_hover: true } end ``` ### Resource configuration If a single resource needs different behavior, set `row_controls_config` on it β€” it overrides the global configuration for that resource: ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource self.row_controls_config = { placement: :left } end ``` Both accept the same keys β€” `placement`, `float`, and `show_on_hover`. See the API reference for values and defaults. :::warning `float` and `show_on_hover` are designed to work with `placement: :right`. Avo doesn't restrict other placements, but the applied styles are intended for `:right` and unexpected behavior may occur with `:left` or `:both`. ::: ## Style rows with `row_options` `self.table_view = { row_options: { ... } }` lets you declaratively set HTML attributes on the `` element for each record on the index, with optional per-record blocks. Use it to highlight rows, add custom data attributes, set tooltips, or attach Stimulus controllers β€” all without overriding the row component. ```ruby # app/avo/resources/message.rb class Avo::Resources::Message < Avo::BaseResource self.table_view = { row_options: { class: -> { record.role == "agent" ? "bg-blue-50 dark:bg-blue-950/40" : "" }, data: { test_id: "message-row" }, title: -> { "Message from #{record.role}" } } } end ``` An Avo index table with ID, Name and Role columns where rows with role agent are highlighted with a light blue background via table_view row_options, while customer rows use the default background. The same configuration applies to both the main index and any `has_many` association table that lists this resource. The supported keys (`class`, `data`, `style`, and other passthrough HTML attributes), the merge rules, and the reserved attributes are documented in the API reference. ### Configuration shape `row_options` accepts a hash whose values may be static or blocks. The whole hash itself can also be a block returning a hash. ```ruby # Per-value blocks (most common) self.table_view = { row_options: { class: -> { record.archived? ? "opacity-60" : "" }, data: { kind: "message" } } } # Top-level block (one evaluation context for many keys) self.table_view = { row_options: -> { { class: record.archived? ? "opacity-60" : "", data: { kind: "message", archived: record.archived?.to_s } } } } ``` Blocks are evaluated once per row, per render, through `Avo::ExecutionContext`. Inside a block you have access to: - `record` β€” the row's record instance - `resource` β€” the Avo resource instance - `view` β€” `:index` on the main index, `:has_many` inside an association panel - Standard `Avo::ExecutionContext` defaults (`current_user`, `params`, `request`, view helpers) :::warning Attribute values must resolve to a `String`, `Symbol`, or `Integer` (or `nil`/`false` to omit the attribute). Booleans like `record.archived?` raise an `ArgumentError` β€” convert them with `.to_s` first. See value coercion in the API reference. ::: ### Examples #### Highlight by record state ```ruby class Avo::Resources::Order < Avo::BaseResource self.table_view = { row_options: { class: -> { case record.status when "failed" then "bg-red-50 dark:bg-red-950/30" when "pending" then "bg-amber-50 dark:bg-amber-950/30" when "shipped" then "bg-emerald-50 dark:bg-emerald-950/30" else "" end } } } end ``` #### Dim soft-deleted rows ```ruby self.table_view = { row_options: { class: -> { "opacity-60 italic" if record.discarded? } } } ``` #### Branch on render context The `view` local lets you apply different styling on the main index versus inside an association panel: ```ruby self.table_view = { row_options: { class: -> { next "" if view == :has_many # quieter inside parent show pages record.urgent? ? "bg-amber-50 dark:bg-amber-950/30" : "" } } } ``` #### Conditional return values Returning `nil` or `false` from a block omits the attribute (or, for `class:`, leaves Avo's classes untouched). This makes ternaries and `if` modifiers natural: ```ruby class: -> { record.archived? ? "opacity-60" : nil } data: -> { record.special? ? { status: "active" } : nil } title: -> { record.note.presence } ``` ### Hover and selection co-existence Avo's row hover and selection styles use Tailwind utilities applied to `` (e.g., `hover:bg-gray-50`). When you set a custom background, you may want hover and selected affordances to remain visible: - Use **semitransparent** backgrounds (`bg-blue-50/60`, `bg-amber-100/40`) so Avo's hover overlay still shows through. - Or pair your custom class with explicit `hover:` and selection variants: `bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/40 dark:hover:bg-blue-900/40`. ### Dark mode User-supplied classes are outside Avo's semantic CSS variable system, so dark-mode handling is your responsibility. Two recommended patterns: - **Tailwind `dark:` modifiers** for utility classes: ```ruby class: "bg-blue-50 dark:bg-blue-950/40" ``` - **Avo's semantic variables** via inline `style:` for theme-aware values: ```ruby style: "background-color: var(--color-secondary);" ``` ### Tailwind class discovery Tailwind only generates utility classes it can see in your source files. With Avo's Tailwind CSS integration enabled, the compiler scans your Rails `app/` directory β€” including `app/avo` β€” so classes written **literally** inside `row_options` blocks (like the examples above) are compiled automatically. Two cases need attention: - **Dynamically-built class names** (`"role-#{record.role.slug}"`, concatenated strings) are invisible to the scanner. Register them explicitly with Tailwind v4's `@source inline(...)` in one of your Avo stylesheets: ```css /* app/assets/stylesheets/avo/custom.css */ @source inline("{dark:,}bg-{red,amber,emerald}-{50,950/30}"); ``` - **Without the integration** (precompiled Avo bundle only), no new utility classes are generated at all β€” only classes already present in Avo's own bundle will work. Enable the Tailwind CSS integration if you rely on custom classes here. ### Performance Blocks run once per row, per render, after Avo's row cache boundary. For tables with many rows or large `per_page` values: - **Preload associations** referenced from blocks via `self.includes`. A naΓ―ve `record.user.role.name` is an N+1 across every row. - Keep blocks cheap β€” they run on every render, not just initial page load. ```ruby self.includes = [:role] self.table_view = { row_options: { class: -> { "role-#{record.role.slug}" } # `role` is preloaded } } ``` ### Migration from row component overrides If you previously overrode `Avo::Index::TableRowComponent` to add a class or data attribute per row, you can usually replace the override with `row_options` and remove the custom component. The new API runs in Avo's stock render path, so resources without `self.table_view` configured behave exactly as they did before. If your override does anything beyond `` attribute customization (e.g., changes which cells render), keep the override β€” `row_options` only controls the `` itself. ### Limitations - **No cell-level options yet.** A future `cell_options` API will let you customize individual `` elements via the field DSL. For now, `row_options` only affects the row container. - **No grid analog.** `self.grid_view` configures the card content, not per-card HTML attributes. - **Turbo Stream re-renders.** When a row is broadcast-updated via Turbo Stream, the `view:` local resolves based on the original render context. Verify in your specs if you depend on `view` branching. --- # Grid view Some resources are best displayed in a grid view. We can do that with Avo using a `cover_url`, a `title`, and a `body`. Avo grid view ## Enable grid view To enable grid view for a resource, you need to configure the `grid_view` class attribute on the resource. That will add the grid view to the view switcher on the view. ```ruby{2-13} class Avo::Resources::Post < Avo::BaseResource self.grid_view = { card: -> do { cover_url: if record.cover_photo.attached? main_app.url_for(record.cover_photo.url) end, title: record.name, body: record.truncated_body } end } end ``` Avo view switcher The `card` block runs once per record through `Avo::ExecutionContext`, with access to `record`, `resource`, and the standard defaults (`current_user`, `params`, view helpers). ## Options Next, you should configure a few things for the grid card. ## Make grid the default view To make the grid the default way of viewing a resource **Index**, we have to use the `default_view_type` class attribute. ```ruby{2} class Avo::Resources::Post < Avo::BaseResource self.default_view_type = :grid end ``` To change the default for **all** resources, set `config.default_view_type = :grid` in `config/initializers/avo.rb`. Both the global and per-resource settings accept a block, evaluated through `Avo::ExecutionContext`, if the choice depends on the request. ## Custom style You may want to customize the card a little bit. That's possible using the `html` option. ```ruby{13-37} class Avo::Resources::Post < Avo::BaseResource self.grid_view = { card: -> do { cover_url: if record.cover_photo.attached? main_app.url_for(record.cover_photo.url) end, title: record.name, body: record.truncated_body } end, html: -> do { title: { index: { wrapper: { classes: "bg-blue-50 dark:bg-blue-900 rounded-md p-2" } } }, body: { index: { wrapper: { classes: "bg-gray-50 dark:bg-gray-800 rounded-md p-1" } } }, cover: { index: { wrapper: { classes: "blur-sm" } } } } end } end ``` Grid html option ## Grid Item Badge Avo Grid View Badge Element You can display and customize a badge on top of your grid items. Badges are useful for showing status indicators, labels, or other visual cues that help users quickly identify important information about each item. ### Complete Example ```ruby # Dynamic badge based on record status self.grid_view = { card: -> do { cover_url: record.image.attached? ? main_app.url_for(record.image.variant(resize_to_fill: [300, 300])) : nil, title: record.title, body: simple_format(record.description), badge: { label: record.new? ? "New" : "Updated", color: record.new? ? "green" : "orange", style: record.new? ? "solid" : "subtle", title: record.new? ? "New product available" : "Recently updated", icon: record.new? ? "heroicons/outline/arrow-trending-up" : "heroicons/outline/arrow-path" } } end } ``` ### Options --- # Map view Some resources that contain geospatial data can benefit from being displayed on a map. For resources to be displayed to the map view they require a `coordinates` field, but that's customizable. ## Enable map view To enable map view for a resource, you need to add the `map_view` class attribute to a resource. That will add the view switcher to the view. The Cities resource in map view β€” the table/map view switcher, a Mapbox map with markers and the adjacent index table. ```ruby class Avo::Resources::City < Avo::BaseResource # ... self.map_view = { mapkick_options: { controls: true }, record_marker: -> { { latitude: record.coordinates.first, longitude: record.coordinates.last, tooltip: record.name } }, map: { position: :left }, table: { visible: true } } end ``` :::warning You need to add the `mapkick-rb` (not `mapkick`) gem to your `Gemfile` and have the `MAPBOX_ACCESS_TOKEN` environment variable with a valid [Mapbox](https://account.mapbox.com/auth/signup/) key. ::: ## Make it the default view To make the map view the default way of viewing a resource on , we have to use the `default_view_type` class attribute. ```ruby{2} class Avo::Resources::City < Avo::BaseResource self.default_view_type = :map end ``` To change the default for **all** resources, set `config.default_view_type = :map` in `config/initializers/avo.rb`. Both the global and per-resource settings accept a block, evaluated through `Avo::ExecutionContext`, if the choice depends on the request. --- # Custom view types Avo ships with three built-in view types for the resource index: **table**, **grid**, and **map**. You can register entirely new view types from a Rails Engine (Avo plugin) β€” they appear in the view switcher alongside the built-in ones and can be set as the default for any resource. If you're only looking to control which of the existing view types show up on a resource, see Restrict the available view types instead. The process has three parts: **create the component**, **register the view type**, and **configure a resource to use it**. ## 1. Create the view type component Every view type is a ViewComponent that inherits from `Avo::ViewTypes::BaseViewTypeComponent`. The base class provides these props automatically: | Prop | Description | | ----------------- | ------------------------------------------------------------- | | `resources` | Array of Avo resource wrappers (call `.record` for the model) | | `resource` | The Avo resource class | | `pagy` | Pagination object | | `query` | The current query | | `turbo_frame` | The Turbo Frame ID | | `index_params` | Current index parameters | | `reflection` | Association reflection (if nested) | | `parent_record` | Parent record (if nested) | | `parent_resource` | Parent resource (if nested) | | `actions` | Available actions | Create your component class inside your engine's namespace: ```ruby # app/components/my_plugin/view_types/timeline_view_type_component.rb class MyPlugin::ViewTypes::TimelineViewTypeComponent < Avo::ViewTypes::BaseViewTypeComponent # [!code highlight] def grouped_resources @resources.group_by { |r| r.record.created_at.to_date } end def empty? @resources.blank? end end ``` Then create the template. You have full control over the HTML β€” render items however you like and include the paginator at the bottom: ```erb <%# app/components/my_plugin/view_types/timeline_view_type_component.html.erb %>
<% if empty? %>
No records found.
<% else %> <% grouped_resources.each do |date, resources| %>

<%= date.strftime("%B %d, %Y") %>

<% resources.each do |resource| %>
<%= resource.record.title %>
<% end %> <% end %> <% end %>
<%= render paginator_component %> ``` :::info The `paginator_component` method is inherited from the base class. Always render it to keep pagination working. ::: ## 2. Register the view type In your engine's initializer, register the view type with `Avo.plugin_manager.register_view_type`. This must happen inside the `ActiveSupport.on_load(:avo_boot)` hook so Avo core is loaded first. ```ruby # lib/my_plugin/engine.rb module MyPlugin class Engine < ::Rails::Engine initializer "my_plugin.init" do ActiveSupport.on_load(:avo_boot) do Avo.plugin_manager.register "my_plugin" # [!code focus:5] Avo.plugin_manager.register_view_type :timeline, component: "MyPlugin::ViewTypes::TimelineViewTypeComponent", icon: "tabler/outline/timeline-event", active_icon: "tabler/filled/timeline-event" end end end end ``` `register_view_type` accepts these options: | Option | Required | Description | | ------------- | -------- | ----------------------------------------------------- | | `component` | Yes | Component class or string (auto-constantized) | | `icon` | Yes | Icon path for the inactive state in the view switcher | | `active_icon` | Yes | Icon path for the active state in the view switcher | :::info The `component` can be passed as a string (`"MyPlugin::ViewTypes::TimelineViewTypeComponent"`) or as the class itself. Strings are constantized at render time, which avoids load-order issues during boot. ::: ## 3. Configure a resource to use it Once registered, you can use your custom view type in any resource: ```ruby # app/avo/resources/event.rb class Avo::Resources::Event < Avo::BaseResource self.default_view_type = :timeline # [!code focus:2] self.view_types = [:table, :timeline] # ... fields end ``` Setting `default_view_type` makes your view type the one users see first. Including `:table` in `view_types` keeps the table view available as a fallback via the view switcher. To change the default for **all** resources, set `config.default_view_type` in `config/initializers/avo.rb`. ## Full example: avo-notifications The `avo-notifications` gem ships a `:notification` view type as a real-world reference. Here's how it's wired up: **Registration** in the engine: ```ruby # lib/avo/notifications/engine_handler.rb Avo.plugin_manager.register_view_type :notification, component: "Avo::Notifications::ViewTypes::NotificationViewTypeComponent", icon: "tabler/outline/bell", active_icon: "tabler/filled/bell" ``` **Component** inherits from the base and adds domain logic (time grouping, unread counts): ```ruby # app/components/avo/notifications/view_types/notification_view_type_component.rb class Avo::Notifications::ViewTypes::NotificationViewTypeComponent < Avo::ViewTypes::BaseViewTypeComponent def grouped_resources @resources.group_by { |resource| time_group(resource.record.created_at) } end def unread_count @resources.count { |resource| user_unread?(resource.record) } end # ... end ``` **Resource** sets it as the only view type: ```ruby # app/avo/resources/avo_notification.rb class Avo::Resources::AvoNotification < Avo::BaseResource self.default_view_type = :notification self.view_types = [:notification] end ``` ## Add styles and interactivity Custom view types often ship their own CSS and Stimulus controllers. Register both from your engine through Avo's asset manager: ```ruby Avo.asset_manager.add_stylesheet "my-plugin/application" Avo.asset_manager.add_javascript "my-plugin/application" ``` See Asset handling for compiling and serving the assets, and JavaScript & Stimulus for wiring up controllers. --- # Fields Fields are the backbone of a `Resource`. Through fields you tell Avo what to fetch from the database and how to display it on the , , and views. Fields can also be used in `Actions` to gather user input before running the action. Avo ships with various simple fields like `text`, `textarea`, `number`, `password`, `boolean`, `select`, and more complex ones like `markdown`, `key_value`, `trix`, `tags`, and `code`. ## Declaring fields You add fields to a resource through the `fields` method using the `field DATABASE_COLUMN, as: FIELD_TYPE, **FIELD_OPTIONS` notation. ```ruby def fields field :name, as: :text end ``` The first argument (`:name` here) is the column in the database where Avo looks for information, or a property on your model. On the and views, we'll get a new text column of that record's database value. Finally, on the and views, we will get a text input field that will display & update the `name` field on that model. ### Specific methods for each view The `fields` method is used whenever no view-specific method is defined. To specify fields for each view or a group of views, you can use the following methods: `index` view -> `index_fields`
`show` view -> `show_fields`
`edit` / `update` views -> `edit_fields`
`new` / `create` views -> `new_fields` You can also register fields for a specific group of views as follows: `index` / `show` views -> `display_fields`
`edit` / `update` / `new` / `create` views -> `form_fields` When specific view fields are defined, they take precedence over view group fields. If neither specific view fields nor view group fields are defined, the fields will be retrieved from the `fields` method. ```ruby class Avo::Resources::City < Avo::BaseResource # Used on the `index` and `show` views def display_fields field :id, as: :id field :name, as: :text field :population, as: :number field :created_at, as: :date_time end # Used on the `new`, `create`, `edit`, and `update` views def form_fields field :name, as: :text field :population, as: :number end end ``` :::info On the `preview` view, Avo gathers fields from the `fields`, `index_fields`, `show_fields`, and `display_fields` methods combined. ::: :::warning Some fields must be declared even when hidden When a form submits a value for a field, that field must be declared on the form views so Avo knows its type and can parse the submitted value. This comes up with resource tools that render inputs for fields themselves: declare those fields with `hide_on: :forms` instead of omitting them. For instance, if a tool renders an input for `features`, a `key_value` field, you must keep `field :features, as: :key_value, hide_on: :forms` in your form fields. Omit it and Avo will be unable to update that database column. ::: ## Field conventions When we declare a field, we pinpoint the specific database column for that field. Usually, that's a snake case value. Each field has a label. Avo will convert the snake case name to a humanized version. In the following example, the `is_available` field will render the label as *Is available*. ```ruby field :is_available, as: :boolean ``` :::info If having the fields stacked one on top of another is not the right layout, try the sidebar. ::: ### A more complex example ```ruby class Avo::Resources::User < Avo::BaseResource def fields field :id, as: :id field :first_name, as: :text field :last_name, as: :text field :email, as: :text field :active, as: :boolean field :cv, as: :file field :is_admin?, as: :boolean end end ``` The `fields` method is already hydrated with the `current_user`, `params`, `request`, `view_context`, and `context` variables so you can use them to conditionally show/hide fields ```ruby class Avo::Resources::User < Avo::BaseResource def fields field :id, as: :id field :first_name, as: :text field :last_name, as: :text field :email, as: :text field :is_admin?, as: :boolean field :active, as: :boolean if current_user.is_admin? field :cv, as: :file end end end ``` User resource Show view with id, first and last name, email, active, cv and is_admin fields ## Field types --- # Field options Every Avo field accepts a set of **common options** that control its label, visibility, formatting, and behavior. This page walks through what you can do with them; the Field options API lists every option's type, default, and accepted values. ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields field :name, as: :text, sortable: true, placeholder: "John Doe" end end ``` With no options, a field shows up on the , , , and views with a humanized version of its id as the label. Besides the common options, some fields respond to **field-specific options** β€” like `options` on the select field β€” documented on each field's page. ## Change the field label Pass `name` to display a different label than the humanized field id. ```ruby field :is_available, as: :boolean, name: "Availability" ``` An Avo index table with three columns β€” ID, Name and a boolean column whose header reads β€œAvailability”, the custom label set via the field's name option, highlighted. If you localize your app, translate the label through the i18n conventions instead of hardcoding it. ## Show and hide fields on different views There will be cases where you want to show fields on some views and hide them on others. For example, you may want to display a field on the and views and hide it on the and views. Use the visibility helpers `hide_on`, `show_on`, `only_on`, and `except_on`. They accept `:index`, `:show`, `:new`, `:edit`, and `:preview`, plus the shorthands `:forms` (`:new` and `:edit`), `:display` (`:index` and `:show`), and `:all` (only for `hide_on` and `show_on`). ```ruby field :body, as: :textarea, hide_on: [:index, :show] ``` Be aware that a few fields override those options β€” for example, the `id` field hides itself on the and views. Please read the detailed views page for more info. ## Show fields conditionally You might want to restrict some fields to be accessible only if a specific condition applies β€” for example, hide fields if the user is not an admin. Use the `visible` option with a boolean or a block. Inside the block, you have access to the `context` object and the current `resource`. The `resource` has the current `record` object, too (`resource.record`). ```ruby field :is_featured, as: :boolean, visible: -> { context[:user].is_admin? } # show field based on the context object field :is_featured, as: :boolean, visible: -> { resource.name.include? 'user' } # show field based on the resource name field :is_featured, as: :boolean, visible: -> { resource.record.published_at.present? } # show field based on a record attribute ``` :::warning On form submissions, the `visible` block is evaluated in the `create` and `update` controller actions. That's why you have to check if the `resource.record` object is present before trying to use it. ::: ```ruby # `resource.record` is nil when submitting the form on resource creation field :name, as: :text, visible: -> { resource.record.enabled? } # Do this instead field :name, as: :text, visible: -> { resource.record&.enabled? } ``` ## Compute the value with a block You might need to show a field with a value you don't have in a database row. In that case, you may compute the value using a block that receives the `record` (the actual database record), the `resource` (the configured Avo resource), and the current `view`. ```ruby field 'Has posts', as: :boolean do record.posts.present? rescue false end ``` :::info Computed fields are displayed only on the and views. ::: ## Format displayed values Sometimes you will want to process the database value before showing it to the user. Inside every formatter block you have access to all the defaults that `Avo::ExecutionContext` provides plus `value`, `record`, `resource`, `view`, and `field`. ### On every view `format_using` formats the value on **all** views β€” including inside the inputs on forms, so return the raw value on form views if the user should edit it. ```ruby field :is_writer, as: :text, format_using: -> { if view.form? value else value.present? ? 'πŸ‘' : 'πŸ‘Ž' end } ``` An Avo index table with three columns β€” ID, Name and an β€œIs writer” column whose cells show a πŸ‘ or πŸ‘Ž emoji rendered via format_using instead of the raw value. ### On specific views If the formatting only applies to certain views, reach for the view-scoped variants β€” `format_display_using`, `format_form_using`, `format_index_using`, `format_show_using`, `format_edit_using`, or `format_new_using` β€” instead of branching on `view` yourself. When several are declared, the most specific one wins; see the precedence table. ```ruby field :is_writer, format_display_using: -> { value.present? ? 'πŸ‘' : 'πŸ‘Ž' } ``` An Avo Show view details panel card laid out in three rows β€” ID spanning the full width on top, First name and Last name side by side, then Is writer and User Email side by side β€” the β€œIs writer” value showing a πŸ‘ emoji rendered via format_display_using. ### With Rails helpers You can format using Rails helpers like `number_to_currency` (note that `view_context` is used to access the helper): ```ruby field :price, as: :number, format_using: -> { view_context.number_to_currency(value) } ``` ## Parse the value before saving When it's necessary to parse information before storing it in the database, the `update_using` option proves to be useful. Inside the block you can access the raw `value` from the form, and the returned value will be saved in the database. ```ruby field :metadata, as: :code, update_using: -> do ActiveSupport::JSON.decode(value) end ``` ## Make columns sortable Add `sortable` to any field to make that column sortable on the view. ```ruby field :name, as: :text, sortable: true ``` An Avo index table for Projects sorted by the Name column, whose header shows the active sort-arrow indicator for the sortable name text field. **Related:** - Add an index on the `created_at` column ### Sort computed fields and associations When using computed fields or `belongs_to` associations, you can't set `sortable: true` because Avo doesn't know what to sort by. Pass a block instead β€” it receives the `query` and the `direction` and must return a query. In the example of a `Post` that `has_many` `Comment`s, you might want to order the posts by which one received a comment the latest: ::: code-group ```ruby{5} [app/avo/resources/post.rb] class Avo::Resources::Post < Avo::BaseResource field :last_commented_at, as: :date, sortable: -> { query.includes(:comments).order("comments.created_at #{direction}") } end ``` ```ruby{4-6} [app/models/post.rb] class Post < ApplicationRecord has_many :comments def last_commented_at comments.last&.created_at end end ``` ::: ## Mark fields as required To indicate that a field is mandatory, use the `required` option, which adds an asterisk to the field as a visual cue. Avo automatically adds the asterisk when the model has a presence validator on the attribute, so you often don't need this option at all. It's purely cosmetic either way β€” add the actual validation to your model (`validates :name, presence: true`). ```ruby field :name, as: :text, required: true # or conditionally field :name, as: :text, required: -> { view == :new } ``` An Avo Edit form text field whose label has a red asterisk marking it as required. ## Prevent users from editing a field Two options render the input as `disabled` on the and views β€” pick based on how much protection you need. `disabled` also ignores the field's value on save. Even if a bad actor re-enables the input in the DOM and submits, the record is not updated. ```ruby field :name, as: :text, disabled: true # or conditionally field :id, as: :number, disabled: -> { view == :edit } ``` An Avo Edit form with a disabled, greyed-out 'Name' text field above a normal, editable 'Website' field β€” the contrast shows the disabled state. `readonly` only disables the input in the UI β€” a user can still re-enable it in the DOM and submit an arbitrary value. Use it for convenience, not protection. ```ruby field :name, as: :text, readonly: true ``` An Avo Edit form with a readonly, greyed-out 'Name' text field above a normal, editable 'Website' field β€” the contrast shows the readonly state. ## Set a default value Use `default` to pre-fill the field on the view (and in action modals) with a fixed value or a block. ```ruby # using a value field :name, as: :text, default: 'John' # using a callback function field :level, as: :select, options: { 'Beginner': :beginner, 'Advanced': :advanced }, default: -> { Time.now.hour < 12 ? 'advanced' : 'beginner' } ``` ## Add help text Use `help` to display extra text β€” plain or HTML β€” below the input on the form views. ```ruby # using the text value field :custom_css, as: :code, theme: 'dracula', language: 'css', help: "This enables you to edit the user's custom styles." # using HTML value field :password, as: :password, help: 'You may verify the password strength
here.' ``` An Avo Edit form 'Custom CSS' text field with a line of help text shown directly below the input explaining what the field does. If the text should appear on every view β€” not just forms β€” use `label_help`, which renders below the field's label. ```ruby field :custom_css, as: :code, theme: 'dracula', language: 'css', label_help: "This enables you to edit the user's custom styles." ``` An Avo Edit form 'Custom css' code field with a line of help text shown directly below the field label explaining what the field does. ## Add a placeholder Some fields support the `placeholder` option, which will be passed to the inputs on the and views when they are empty. ```ruby field :name, as: :text, placeholder: 'John Doe' ``` An Avo New form text field whose empty input shows the grey placeholder text β€œJohn Doe”. ## Place fields on the same row The `width` option controls how much horizontal space a field takes inside its parent panel or card. Adjacent fields with a `width` below `100` (a percentage) sit side by side. ```ruby field :first_name, width: 50 field :last_name, width: 50 field :years_of_experience # full width ``` Setting any `width` below `100` automatically marks the field as `stacked` β€” the label moves above the value so the field fits the narrower column. See the supported values in the reference. ### Stack the label above the value For some fields, it might make more sense to use all of the horizontal area to display the value. Change the layout of the field wrapper using the `stacked` option. ```ruby field :meta, as: :key_value, stacked: true ``` #### `inline` layout (default) An Avo show view key_value 'Meta' field in the default inline layout, the field label beside a key/value control listing three pairs (environment: production, region: eu-west, tier: premium). #### `stacked` layout An Avo show view key_value 'Meta' field in the stacked layout, the field label shown above a key/value control listing three pairs (environment: production, region: eu-west, tier: premium). ### Global `stacked` layout You may also set all the fields to follow the `stacked` layout by changing the `field_wrapper_layout` initializer option from `:inline` (default) to `:stacked`. ```ruby # config/initializers/avo.rb Avo.configure do |config| config.field_wrapper_layout = :stacked end ``` Now, all fields will have the stacked layout throughout your app. Avo 4 also adds `use_stacked_fields`, which stacks every field at the CSS level: ```ruby # config/initializers/avo.rb Avo.configure do |config| config.use_stacked_fields = true # default: false end ``` With it enabled, fields render stacked by default without needing `stacked: true` on each one, and you can still override per field. ## Store empty values as `NULL` When a user saves a form, Avo stores the value for each field in the database as-is. If you prefer to store `NULL` when the field is empty, use the `nullable` option β€” it converts `nil` and empty values to `NULL`. You may also define which values should be interpreted as `NULL` using `null_values`. ```ruby # using default null values (nil and "") field :body, as: :textarea, nullable: true # using custom null values field :body, as: :textarea, nullable: true, null_values: ['0', '', 'null', 'nil', nil] ``` ## Link the table cell to the record Sometimes, on the view, you may want a field in the table to be a link to that resource so that you don't have to scroll to the right to click the icon. Use `link_to_record` to change a table cell into a link to that record. It's available on the `id`, `text`, `gravatar`, and `belongs_to` fields. ```ruby field :id, as: :id, link_to_record: true field :name, as: :text, link_to_record: true ``` An Avo index table where the Name column cells are rendered as blue links to each record via link_to_record. Optionally you can enable the global config `id_links_to_resource`, which links every `id` field automatically. More on that on the customization page. ## Summarize a column The `summarizable` option generates a visual summary of a column's data distribution. A chart icon appears in the table header; clicking it displays a summary chart based on the data in that column. ```ruby field :status, as: :select, summarizable: true ``` An animated Avo Projects index table (ID, Name, Status, Country columns) with the summarizable summary popover open over it, cycling a hover across each segment of the Status distribution pie chart to reveal each value's label and count. ## Let users copy the value The `copyable` option shows a clipboard icon when hovering over the field's value, allowing easy copying. Particularly useful for unique identifiers, URLs, or other text users frequently need to copy. ```ruby field :name, as: :text, copyable: true ``` :::info The copied value is the displayed value. If you truncate it with `format_using`, the truncated text is what gets copied β€” use CSS truncation via the `html` option if you need to display a short value but copy the full one. ::: ## Align text on the Index view It's customary on tables to align numbers to the right. You can do that using the `html` option, which attaches classes, styles, and data attributes to the field's elements β€” see the HTML attributes page for everything it can do. ```ruby{2} class Avo::Resources::Project < Avo::BaseResource field :users_required, as: :number, html: {index: {wrapper: {classes: "text-right"}}} end ``` An Avo index table where the Users required number column is right-aligned via the html option, its numbers hugging the right edge of the column, contrasting with the left-aligned text columns. ## Customize the field components The `components` option lets you swap the view components used to render the field on the `index`, `show`, and `edit` views. ### Eject the field components To start customizing, eject one or multiple field components using the `avo:eject` command β€” it generates the files for all of the field type's components: ```bash rails g avo:eject --field-components text --scope admin ``` :::warning Scope If you don't pass a `--scope` when ejecting a field view component, the ejected component will override the default components all over the project. Check the eject documentation for more details. ::: ### Point the field at your components Pass a hash (or a block returning one) with `_component` keys: ::: code-group ```ruby [Hash] field :description, as: :text, components: { index_component: Avo::Fields::Admin::TextField::IndexComponent, show_component: Avo::Fields::Admin::TextField::ShowComponent, edit_component: "Avo::Fields::Admin::TextField::EditComponent" } ``` ```ruby [Block] field :description, as: :text, components: -> do { show_component: Avo::Fields::Admin::TextField::ShowComponent, edit_component: "Avo::Fields::Admin::TextField::EditComponent" } end ``` ::: ## Target a different database attribute Use `for_attribute` to point a field at a different model attribute than its id β€” for example, to declare two fields backed by the same attribute with different presentations: ```ruby field :status, as: :select, options: [:one, :two, :three], only_on: :forms field :secondary_field_for_status, as: :badge, for_attribute: :status, options: {info: :one, success: :two, warning: :three}, except_on: :forms, help: "Secondary field for status using the for_attribute option" ``` ## Pass arbitrary data to the field The `meta` option sends arbitrary information to the field β€” especially useful when you're building your own custom fields or using custom [components](#customize-the-field-components) for the built-in fields. ```ruby # meta as a hash field :status, as: :custom_status, meta: {foo: :bar} # meta as a block field :status, as: :badge, meta: -> do record.statuses.map(&:id) end ``` Within your field template you can now access the `@field.meta` attribute: ```erb{2} <%= field_wrapper **field_wrapper_args do %> <% if @field.meta[:foo] %> <%= @resource.record.foo_value %> <% else %> <%= @field.value %> <% end %> <% end %> ``` ## React to changes in other fields The `react_on` option re-evaluates a field when other fields change in the form, refreshing `@record` with the latest form values. Updates run when the watched field's value is committed β€” on selection for selects and checkboxes, and when the input loses focus for text fields. This feature is provided by the **`avo-reactive_fields`** add-on. Add the gem to your app before using `react_on` (see the Avo 4 upgrade guide for the `packager.dev` source). ### Dependent select In the example below, the `city` field reacts whenever the `country` select changes, so the available city options are always relevant to the selected country: ```ruby{11} # app/avo/resources/course.rb class Avo::Resources::Course < Avo::BaseResource def fields field :country, as: :select, options: Course.countries, include_blank: "No country" field :city, as: :select, react_on: :country, options: -> { Course.cities.dig(@record.country&.to_sym) || [""] } end end ``` ### Derived value (slug from name) Pair `react_on` with `format_using` to re-compute a derived value whenever another field changes. When the user fills in **name** (for example `Hello World`) and the input loses focus, **slug** updates to `hello_world` β€” on each reactive request, `@record` is hydrated from the submitted form params, so `format_using` always sees the latest **name**, even before save: ```ruby # app/avo/resources/course.rb class Avo::Resources::Course < Avo::BaseResource def fields field :name field :slug, react_on: :name, format_using: -> { @record.name&.downcase&.gsub(" ", "_") } end end ``` :::tip To retrieve the original value of a field before it was changed, use the [`*_was`](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-2A_was) methods. ::: --- # HTML attributes Using the `html` option you can attach `style`, `classes`, and `data` attributes. The `style` attribute adds the `style` tag to your element, `classes` adds the `class` tag, and the `data` attribute the `data` tag to the element you choose. Pass the `style` and `classes` attributes as strings, and the `data` attribute a Hash. ```ruby{4-11} field :name, as: :text, html: { edit: { wrapper: { style: "background: red; text: white;" # string classes: "absolute h-[41px] w-full" # string data: { action: "input->resource-edit#toggle", resource_edit_toggle_target_param: "skills_tags_wrapper", } # Hash } } } ``` ## Declare the fields from the outside in When you add these attributes, you need to think from the outside in. So first the `view` (`index`, `show`, or `edit`), next the element to which you add the attribute (`wrapper`, `label`, `content` or `input`), and then the attribute `style`, `classes`, or `data`. **The `edit` value will be used for both the `Edit` and `New` views.** There are two notations through which you can attach the attributes; `object` or `block` notation. ## The `object` notation This is the simplest way of attaching the attribute. You usually use this when you want to add _static_ content and params. ```ruby{3-9} field :has_skills, as: :boolean, html: { edit: { wrapper: { classes: "hidden" } } } ``` In this example, we're adding the `hidden` class to the field wrapper on the `Edit` and `New` views. ## The `block` notation You can use the' block' notation if you need to do a more complex transformation to add your attributes. You'll have access to the `params`, `current_user`, `record`, and `resource` variables. It's handy in multi-tenancy scenarios and when you need to scope out the information across accounts. ```ruby{3-18} field :has_skills, as: :boolean, html: -> do edit do wrapper do classes do "hidden" end data do if current_user.admin? { action: "click->admin#do_something_admin" } else { record: record, resource: resource, } end end end end end ``` For the `data`, `style`, and `classes` options, you may use the `method` notation alongside the block notation for simplicity. ```ruby{6,7} field :has_skills, as: :boolean, html: -> do edit do wrapper do classes("hidden") data({action: "click->admin#do_something_admin"}) end end end ``` ## Where are the attributes added? You can add attributes to the wrapper element for the `index`, `show`, or `edit` blocks. --- # Field Discovery Typically you declare each field explicitly in your resource. Field discovery is the alternative: `discover_columns` and `discover_associations` inspect your model's database columns and associations and configure the fields for you. ```rb{4-5} # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields discover_columns discover_associations end end ``` Called with no arguments, discovery maps every column and association using Avo's default mappings β€” `string` columns become `text` fields, `belongs_to` associations become `belongs_to` fields, and so on. Rails enums are detected automatically and rendered as `select` fields, and sensitive columns like `encrypted_password` or `password_digest` are never discovered.
## Scope which fields are discovered If you only want a few fields discovered, pass `only`. To discover everything except a few, pass `except`. ```rb{6-7} # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource # ... def fields discover_columns only: [:title, :body, :published_at] discover_associations except: [:audit_logs] end end ``` :::info Rich text bodies and tags are discovered by `discover_columns`, while attachments and regular associations are discovered by `discover_associations`. Scope them from the matching method β€” `discover_associations only: [:tags]` has no effect. ::: ## Pass options to every discovered field Any other keyword argument is forwarded to every discovered field. This is useful when you'd otherwise repeat the same option across many fields. ```rb{6-7} # app/avo/resources/post.rb class Avo::Resources::Post < Avo::BaseResource # ... def fields discover_columns help: "Automatically discovered fields" discover_associations searchable: false end end ``` ## Combine manual and discovered fields Discovery plays nicely with explicit declarations β€” fields render in the order they're defined. Use `except` to avoid declaring a field twice. ```rb{6,8-9,11} # app/avo/resources/project.rb class Avo::Resources::Project < Avo::BaseResource # ... def fields field :custom_field, as: :text discover_columns except: [:custom_field] discover_associations field :another_custom_field, as: :boolean end end ``` ## Override how columns map to fields Avo decides each column's field type by checking, in order: the model's Rails enums, the column's name, then the column's database type. You can override the last two globally from the initializer. If a column *name* should always get a specific field, add it to `column_names_mapping`: ```rb{5-8} # config/initializers/avo.rb Avo.configure do |config| # ... config.column_names_mapping = { published_at: {field: :date_time}, body: {field: :markdown} } end ``` If a database *type* should map to a different field, use `column_types_mapping`: ```rb{5-8} # config/initializers/avo.rb Avo.configure do |config| # ... config.column_types_mapping = { jsonb: {field: :code, language: "json"}, decimal: {field: :number, decimals: 2} } end ``` Both merge on top of Avo's built-in mappings, so you only list the entries you want to change. Everything besides the `field` key is passed to the field as options. --- # Fields layout Avo gives you a composable DSL to control how fields are arranged on resource show and edit pages. You nest fields inside panels, split a panel into a main area and a sidebar, group panels under tabs, and control where the resource header appears β€” all from the `fields` method. ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields field :id, as: :id field :name, as: :text field :email, as: :text end end ``` With no explicit structure, Avo wraps your root-level fields in a computed **main panel** and places a **header** at the top automatically β€” so a resource looks right before you touch any layout DSL. ## Layout building blocks | Block | Purpose | Nesting | | --------- | -------------------------------------------------------------------- | --------------------------- | | `header` | Title, description, profile photo, discreet information and controls | Root level | | `panel` | Groups related fields inside a titled container | Root level or inside `tabs` | | `card` | Lightweight grouping β€” useful for sectioning fields visually | Root, panel, sidebar or tab | | `sidebar` | Narrow column for compact fields (boolean, date, badge…) | Inside a panel | | `tabs` | Tabbed container that switches between panels and tools | Root level | As a resource grows, declare the structure explicitly to get full control: ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields tool Avo::ResourceTools::UserTool header # render the header only if you want to add a tool or card above it panel title: "User information", description: "Some information about this user" do field :id, as: :id, link_to_record: true field :first_name, as: :text field :last_name # omitting the `as:` option renders the field as :text sidebar do # works only inside a panel card do field :active, as: :boolean, only_on: :show field :created_at, as: :date_time, only_on: :show end end end tabs do tab title: "Projects" do field :projects, as: :has_many end field :teams, as: :has_many # some fields (associations) have their own wrappers and we don't need to wrap them in a tab tab title: "Settings" do field :role, as: :select, enum: ::User.roles field :verified, as: :boolean end end end end ``` ## Header Every resource page gets a header for free: the band of chrome at the top holding the cover image, title, description, avatar, discreet info, and the control buttons (edit, save, delete, actions, back). If you never mention `header`, Avo builds one and pins it to the very top of the page β€” so you don't declare it unless you want it somewhere else. Reach for the `header` DSL only when that default top placement is wrong β€” for example, to slip an intro card, resource tool, or panel above the header, or to sit the header between two panels. Declaring `header` yourself opts out of the automatic one, and it renders exactly where you place it in `fields`: ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource self.title = :name def fields card do field :status, as: :badge end header # the page header now renders below the card instead of at the top panel do field :id, as: :id field :email, as: :text end end end ``` `header` is a positioning marker, not a content hook: it takes no options, and everything it shows (title, description, cover, avatar, controls) comes from the resource itself. To customize what appears, set the resource's `title`, `description`, cover, and so on β€” not the `header` call. :::info Don't confuse `header` with `heading`. `header` is the page-level chrome positioned here; `heading` is a field type for inline section titles inside a panel or form. When a resource is embedded in a modal, the header is dropped from the edit view β€” its title and controls move into the modal's own chrome. ::: ## Group fields into panels Panels are the backbone of Avo's display β€” most information on a page lives inside one. When you don't declare any panels, Avo groups root-level fields into computed panels for you; add your own `panel` blocks to group related fields under a title and description. ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields field :id, as: :id, link_to_record: true field :email, as: :text, name: "User Email", required: true panel title: "User information", description: "Some information about this user" do field :first_name, as: :text, required: true, placeholder: "John" field :last_name, as: :text, required: true, placeholder: "Doe" field :active, as: :boolean, name: "Is active", show_on: :show end end end ``` An Avo show page with the record title and action buttons in the header, a main panel card with ID and User Email fields, and a named User information panel with First name, Last name, and Is active. Set `title` and `description` to label a panel, and `visible` to show or hide a whole panel and its children at once. Use `card` blocks to section fields visually without opening a new panel. ### How Avo computes panels Avo organizes panels behind the scenes so you don't have to. As long as you haven't declared a `panel` of your own, it splits your fields into those that carry their own panel (most associations, like `field :users, as: :has_many`) and "standalone" fields, preserving declaration order, then wraps each standalone group in a computed panel. A single run of standalone fields becomes the **main panel**: ```ruby def fields field :id, as: :id field :name, as: :text field :user, as: :belongs_to field :type, as: :text end ``` An Avo show page with a single computed main panel containing ID, Name, User, and Type fields. Insert a field that owns its panel (like a `has_many`) between standalone fields and Avo splits them: each standalone group β€” before and after the association β€” is wrapped in its own computed panel. ```ruby{5} def fields field :id, as: :id field :name, as: :text field :reviews, as: :has_many field :user, as: :belongs_to field :type, as: :text end ``` An Avo show page with a computed main panel for ID and Name, a Reviews has_many association panel, and a second panel for User and Type. To group fields under your own titled container, add a `panel` block. Declaring a panel yourself turns the automatic computing off β€” Avo takes it as a sign you're in control of the layout. ### Index view fields The **Index** table builds its columns from root-level fields and from fields inside root-level `panel` and `card` blocks alike. Only `sidebar` contents and fields inside tabs stay off the table. To keep a panel's fields out of the Index view, hide the whole panel there with `except_on: :index` β€” or hide individual fields the same way. ```ruby{11} class Avo::Resources::User < Avo::BaseResource def fields # Visible on Index field :id, as: :id, link_to_record: true field :email, as: :text, name: "User Email", required: true field :name, as: :text, only_on: :index do "#{record.first_name} #{record.last_name}" end # Hidden on Index panel title: "User information", except_on: :index do field :first_name, as: :text, required: true, placeholder: "John" field :last_name, as: :text, required: true, placeholder: "Doe" field :active, as: :boolean, name: "Is active", show_on: :show end end end ``` ## Move compact fields to a sidebar Some fields β€” booleans, dates, badges β€” don't need the full width of the main area. Put them in a `sidebar` block, declared inside a panel, to stack them in a narrower column beside the main content. Standalone fields are auto-wrapped in a card β€” declare a `card` yourself only to title the group β€” and each panel can have its own sidebar. ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields panel do field :id, as: :id, link_to_record: true field :first_name, as: :text, placeholder: "John" field :last_name, as: :text, placeholder: "Doe" # Custom resource tools work here too tool UserTimeline sidebar do card do field :email, as: :gravatar, link_to_record: true, only_on: :show field :active, as: :boolean, name: "Is active", only_on: :show end end end end end ``` Resource Show view with a main panel on the left and a sidebar on the right holding the avatar and Is active fields Sidebar fields are always stacked β€” label above value β€” because the narrower column requires it. ## Organize fields under tabs When a resource grows past what panels comfortably hold, group panels and tools under `tabs`. Open a `tabs` block, then add `tab` blocks that contain fields exactly as you would at the root. Standalone fields are auto-wrapped, so a `panel` or `card` inside a tab is **optional** β€” add one only when you want a title or description on that group. Association fields (`has_one`, `has_many`, `has_and_belongs_to_many`) bring their own panels. ```ruby # app/avo/resources/user.rb class Avo::Resources::User < Avo::BaseResource def fields field :id, as: :id, link_to_record: true field :email, as: :text, name: "User Email", required: true tabs do tab title: "User information", description: "Some information about this user" do # No panel needed β€” these fields are auto-wrapped field :first_name, as: :text, required: true, placeholder: "John" field :last_name, as: :text, required: true, placeholder: "Doe" field :active, as: :boolean, name: "Is active", show_on: :show end field :teams, as: :has_and_belongs_to_many field :people, as: :has_many field :projects, as: :has_and_belongs_to_many end end end ``` An Avo User show page with id and User Email in the main panel, a tab switcher listing User information, Teams, People, Spouses and Projects, and the User information panel showing first name, last name and the Is active boolean. The tab `title` is mandatory and labels the switcher; the `description` shows as a tooltip on hover. Both `tabs` groups and individual `tab`s accept a `visible` boolean or lambda, and the whole group takes its own `title` and `description`. Add a `badge` to a tab β€” a string or a renderable like `Avo::UI::CountComponent` β€” to show a count or status pill next to its label. Tab and panel titles also localize through the resource's translation key. ### Loading behavior on Show and Edit On the **Show** page, `has_many`-type fields and tools inside tabs lazy-load only when their tab is displayed, keeping the initial page light. For heavy tabs you'd rather not fetch on every view, set `loading: :manual` to render a **Load** button and defer the fetch until the user asks for it. A tab's own content renders eagerly by default; set `lazy_load` to defer it until the tab is first revealed. On **Edit**, `has_*` fields stay hidden by default (add `show_on: :edit` to reveal them); all other fields load and hide so form validations on fields in an inactive tab still fire on submit. ### Durable and bookmarkable selection Tab selection is durable across view changes β€” each tab group remembers its active tab β€” and bookmarkable, so a link can carry a specific tab. Both rely on a unique tab-group ID; without one Avo falls back to the group's title, then to its position, so assign an `id` to keep links stable as groups get renamed or reordered: ```ruby {1} tabs id: :some_random_uniq_id do field :posts, as: :has_many, show_on: :edit end ``` To add a record-count badge to association tabs, see the tabs counter indicator recipe. ## Position labels: inline vs. stacked Every field has two layout modes for how its label sits relative to its value. **Inline** (default) places them side by side; **stacked** puts the label above the value, giving it the full width β€” handy for wide fields like `key_value`, `trix`, `code`, or `markdown`. ```ruby field :meta, as: :key_value, stacked: true ``` Fields inside a `sidebar` are stacked automatically. To stack every field across the app, set `config.field_wrapper_layout = :stacked`. See `stacked` in the field options reference and global stacked layout in the guide for the full details. ## Multi-column rows with `width` To place fields on the same row, pass a `width` percentage to each β€” adjacent fields below `100` sit side by side. ```ruby field :first_name, width: 50 field :last_name, width: 50 ``` Supported values are `25`, `33`, `50`, `66`, `75`, and `100` (default). See `width` in field options for the full reference. --- # Area The `Area` field is used to display a geographical area on a map. ```ruby field :city_center_area, as: :area ``` :::warning You need to add the `mapkick-rb` (not `mapkick`) gem to your `Gemfile` and have the `MAPBOX_ACCESS_TOKEN` environment variable with a valid [Mapbox](https://account.mapbox.com/auth/signup/) key. ::: ## Description The field reads a [GeoJSON](https://geojson.org) `coordinates` array from one database column (a `json` column works great) and renders it as an interactive area map on the view. ```ruby # Sample value for a polygon [[[2.3342, 48.8674], [2.3396, 48.8600], [2.3253, 48.8567], [2.3245, 48.8639], [2.3342, 48.8674]]] ``` On the view you'll get a text input where you can edit the coordinates as JSON. The field is hidden on the view by default. ## Options ## Full example ```ruby field :city_center_area, as: :area, geometry: :polygon, mapkick_options: { style: "mapbox://styles/mapbox/satellite-v9", controls: true }, datapoint_options: { label: "Paris City Center", tooltip: "Bonjour mes amis!", color: "#009099" } ``` --- # Array The `Array` field allows you to display and manage structured array data. This field supports flexibility in fetching and rendering data, making it suitable for various use cases. :::tip Important To use the `Array` field, you must create a resource specifically for it. Refer to the Array Resource documentation for detailed instructions. For example, to use `field :attendees, as: :array`, you can generate an array resource by running the following command: ```bash bin/rails generate avo:resource Attendee --array ``` This step ensures the proper setup of your array field within the Avo framework. ::: ## Example 1: Array field with a block You can define array data directly within a block. This is useful for static or pre-configured data: ```ruby{4-9} # app/avo/resources/course.rb class Avo::Resources::Course < Avo::BaseResource def fields field :attendees, as: :array do [ { id: 1, name: "John Doe", role: "Software Developer", organization: "TechCorp" }, { id: 2, name: "Jane Smith", role: "Data Scientist", organization: "DataPros" } ] end end end ``` :::warning Authorization The `array` field internally inherits many behaviors from `has_many`, including authorization. If you are using authorization and the array field is not rendering, it is most likely not authorized. To explicitly authorize it, define the following method in the resource's policy: ```ruby{3} # app/policies/course_policy.rb class CoursePolicy < ApplicationPolicy def view_attendees? = true end ``` For more details, refer to the view\_{association}? documentation. ::: ## Example 2: Array field fetching data from the model's method If no block is defined, Avo will attempt to fetch data by calling the corresponding method on the model: ```ruby # app/models/course.rb class Course < ApplicationRecord def attendees User.all.first(6) # Example fetching first 6 users end end ``` Here, the `attendees` field will use the `attendees` method from the `Course` model to render its data dynamically. ## Example 3: Fallback to the `records` method If neither the block nor the model's method exists, Avo will fall back to the `records` method defined in the resource used to render the array field. This is useful for providing a default dataset: ```ruby # app/avo/resources/attendee.rb class Avo::Resources::Attendee < Avo::Resources::ArrayResource def records [ { id: 1, name: "Default Attendee", role: "Guest", organization: "DefaultOrg" } ] end end ``` ## Summary of Data Fetching Hierarchy When using the `Array` field, Avo will fetch data in the following order: 1. Use data returned by the **block** provided in the field. 2. Fetch data from the **associated model method** (e.g., `Course#attendees`). 3. Fall back to the **`records` method** defined in the resource. This hierarchy provides maximum flexibility and ensures seamless integration with both dynamic and predefined datasets. --- # Avatar The `Avatar` field is a field that displays a user's avatar or initials. An Avo index table with three columns β€” ID, an Avatar column showing each user's square avatar thumbnail, and Name β€” illustrating the avatar field rendered on the Index view. ```ruby field :avatar, as: :avatar ``` It does not take any option and is visible only on the view. --- # Badge The `badge` field is used to display an easily recognizable status of a record. Two Avo panels showing every badge color the field can render at its native size, grouped by category: a β€œBase colors” panel with red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink and rose badge pills, and a β€œSemantic colors” panel with neutral, info, success, warning and danger badge pills β€” each pill labelled with its color name. ```ruby field :status, as: :badge, options: { success: "Done", danger: "Cancelled", warning: "On hold", green: "In review", purple: "Idea" } ``` ## Description The Badge field displays a colored indicator with optional icons. You can customize the color through the `options` mapping, and the `style` and `icon` for each value dynamically using procs. The `Badge` field is intended to be displayed only on **Index** and **Show** views. To update the value shown by the badge field, use another field like Text or Select with `hide_on: [:index, :show]`. ## Options ## Examples ### Using semantic colors ```ruby field :status, as: :badge, options: { success: ["active", "completed"], info: ["pending", "review"], danger: ["failed", "cancelled"], neutral: ["unknown"] } ``` ### Using base colors ```ruby field :priority, as: :badge, options: { green: :low, amber: :medium, orange: :high, red: :urgent } ``` ### Using Badge with a Select field for editing Since Badge is display-only, pair it with a Select field to allow editing: ```ruby field :stage, as: :select, hide_on: [:show, :index], options: { 'Discovery': :discovery, 'Idea': :idea, 'Done': :done, 'On hold': 'on hold', 'Cancelled': :cancelled, 'Drafting': :drafting }, placeholder: 'Choose the stage.' field :stage, as: :badge, options: { info: ["Discovery", "Idea"], success: :Done, warning: "On hold", danger: "Cancelled", neutral: :Drafting }, style: -> { ["Done", "Cancelled"].include?(record.stage) ? "solid" : "subtle" }, icon: -> { { "Discovery" => "tabler/outline/zoom", "Idea" => "tabler/outline/bulb", "Drafting" => "tabler/outline/file-text", "Done" => "tabler/outline/circle-check", "On hold" => "tabler/outline/player-pause", "Cancelled" => "tabler/outline/xbox-x" }[record.stage] } ``` --- # Boolean The `Boolean` field renders a `input[type="checkbox"]` on **Form** views and a nice green `check` icon/red `X` icon on the **Show** and **Index** views. ```ruby field :is_published, as: :boolean, name: 'Published', true_value: 'yes', false_value: 'no' ``` An Avo index table with ID, Name and Published columns β€” a green check and a red X in the Published column. ## Options --- # Boolean Group The `BooleanGroup` is used to update a `Hash` with `string` keys and `boolean` values in the database. It's useful when you have something like a roles hash in your database. ### DB payload example An example of a boolean group object stored in the database: ```ruby { "admin": true, "manager": true, "writer": true, } ``` ### Field declaration example Below is an example of declaring a `boolean_group` field for roles that matches the DB value from the example above: ```ruby field :roles, as: :boolean_group, name: "User roles", options: { admin: "Administrator", manager: "Manager", writer: "Writer" } ``` An Avo index table with three columns β€” ID, Name and a β€œUser roles” boolean_group column rendered as a β€œView” link per row β€” with the popover open over a middle row listing Administrator, Manager and Writer each with a checked or unchecked box. ## Options ## Updates Avo only updates the keys that you send from the client. Other keys in the stored hash are left unchanged. ```json // Before update. { "feature_enabled": true, "another_feature_enabled": false, "something_else": "some_value" // this will be kept } // After update. { "feature_enabled": true, "another_feature_enabled": false, "something_else": "some_value" } ``` --- # Checkbox List The `CheckboxList` field renders a list of checkboxes for selecting multiple values from a finite set of options. Use it when the available options are already known and users should see the choices directly instead of opening a select. ```ruby field :team_member_ids, as: :checkbox_list, options: -> { User.active.order(:name).map do |user| { id: user.id, title: user.name, avatar_url: user.avatar_url, image_format: :circle, description: user.email } end }, inline_search: true ``` An Avo edit-form card containing a checkbox_list field: an animation that checks three team members one after another from an initially empty selection. The field submits an array of selected option ids. This works well with Rails collection writers like `team_member_ids=`. ## Options --- # Code The `Code` field generates a code editor using [codemirror](https://codemirror.net/) package. This field is hidden on **Index** view. ```ruby field :custom_css, as: :code, theme: 'dracula', language: 'css' ``` An Avo edit-form card containing a code field: a CodeMirror editor in the dracula dark theme with line-number gutter, showing syntax-highlighted CSS (a .user-card rule with display, padding, border-radius and background properties). ## Options --- # Country `Country` field generates a Select field on **Edit** view that includes all [ISO 3166-1](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) countries. The value stored in the database will be the country code, and the value displayed in Avo will be the name of the country. :::warning You must manually require the `countries` gem in your `Gemfile`. ```ruby # All sorts of useful information about every country packaged as convenient little country objects. gem "countries" ``` ::: ```ruby field :country, as: :country, display_code: true ``` ## Options --- # Date The `Date` field may be used to display date values. ```ruby field :birthday, as: :date, first_day_of_week: 1, picker_format: "F J Y", format: "yyyy-LL-dd", placeholder: "Feb 24th 1955" ``` ## Options --- # DateTime The `DateTime` field is similar to the Date field with two new attributes. `time_24hr` tells flatpickr to use 24 hours format and `timezone` to tell it in what timezone to display the time. By default, it uses your browser's timezone. ```ruby field :joined_at, as: :date_time, name: "Joined at", picker_format: "Y-m-d H:i:S", format: "yyyy-LL-dd TT", time_24hr: true, timezone: "PST" ``` DateTime field ## Options --- # EasyMDE :::info Before Avo 3.17 this field was called `markdown`. It was renamed to `easy_mde` so we can add our own implementation with `markdown`. ::: An Avo create-form card containing an easy_mde field: the EasyMDE Markdown editor with its toolbar (bold, italic, heading, lists, link and preview controls) above a text area pre-filled with sample Markdown β€” a Release notes document with a heading, bold text, a bullet list and a link. The `easy_mde` field renders a [EasyMDE Markdown Editor](https://github.com/Ionaru/easy-markdown-editor) and is associated with a text or textarea column in the database. `easy_mde` field converts text within the editor into raw Markdown text and stores it back in the database. ```ruby field :description, as: :easy_mde ``` :::info The `easy_mde` field is hidden from the **Index** view. ::: ## Options --- # External image You may have a field in the database that has the URL to an image, and you want to display that in Avo. That is where the `ExternalImage` field comes in to help. It will take that value, insert it into an `image_tag`, and display it on the `Index` and `Show` views. ```ruby field :logo, as: :external_image ``` ## Options All options can be static values or procs that are executed within Avo's execution context. When using procs, you have access to all the defaults that `Avo::ExecutionContext` provides plus: - `record` - `resource` - `view` - `field` ## Conditional sizing based on view You can use procs to set different image dimensions and styling based on the current view: ```ruby field :logo, as: :external_image, width: -> { view.index? ? 40 : 150 }, height: -> { view.index? ? 40 : 150 }, radius: -> { view.index? ? 4 : 12 } ``` This example will display smaller, slightly rounded images on the index view (40x40px with 4px radius) and larger, more rounded images on the show view (150x150px with 12px radius). ## Use computed values Another common scenario is to use a value from your database and create a new URL using a computed value. ```ruby field :logo, as: :external_image do "//logo.clearbit.com/#{URI.parse(record.url).host}?size=180" rescue nil end ``` ## Use in the Grid `cover` position Another common place you could use it is in the grid `:cover` position. ```ruby cover :logo, as: :external_image, link_to_record: true do "//logo.clearbit.com/#{URI.parse(record.url).host}?size=180" rescue nil end ``` --- # File :::warning You must manually require `activestorage` and `image_processing` gems in your `Gemfile`. ```ruby # Active Storage makes it simple to upload and reference files gem "activestorage" # High-level image processing wrapper for libvips and ImageMagick/GraphicsMagick gem "image_processing" ``` ::: The `File` field is the fastest way to implement file uploads in a Ruby on Rails app using [Active Storage](https://edgeguides.rubyonrails.org/active_storage_overview.html). Avo will use your application's Active Storage settings with any supported [disk services](https://edgeguides.rubyonrails.org/active_storage_overview.html#disk-service). ```ruby field :avatar, as: :file ``` Avo detects the attachment type via Active Storage and renders images, audio, and video accordingly. ## Authorization :::info Please ensure you have the `upload_{FIELD_ID}?`, `delete_{FIELD_ID}?`, and `download_{FIELD_ID}?` methods set on your model's **Pundit** policy. Otherwise, the input and download/delete buttons will be hidden. ::: **Related:** - Attachment pundit policies ## Variants When using the `file` field to display an image, you can opt to show a processed variant of that image. This can be achieved using the `format_using` option. ### Example: ```ruby{3-5} field :photo, as: :file, format_using: -> { value.variant(resize_to_limit: [150, 150]).processed.image } ``` ## Options --- # Files :::warning You must manually require `activestorage` and `image_processing` gems in your `Gemfile`. ```ruby # Active Storage makes it simple to upload and reference files gem "activestorage" # High-level image processing wrapper for libvips and ImageMagick/GraphicsMagick gem "image_processing" ``` ::: The `Files` field is similar to `File` and enables you to upload multiple files at once using the same easy-to-use [Active Storage](https://edgeguides.rubyonrails.org/active_storage_overview.html) implementation. ```ruby field :documents, as: :files ``` ## Options ## Authorization :::info Please ensure you have the `upload_{FIELD_ID}?`, `delete_{FIELD_ID}?`, and `download_{FIELD_ID}?` methods set on your model's **Pundit** policy. Otherwise, the input and download/delete buttons will be hidden. ::: **Related:** - Attachment pundit policies --- # Gravatar The `Gravatar` field turns an email field from the database into an avatar image if it's found in the [Gravatar](https://en.gravatar.com/site/implement/images/) database. ```ruby field :email, as: :gravatar, rounded: false, size: 60, default: 'some image url' ``` ## Options ## Using computed values You may also pass in a computed value. ```ruby field :email, as: :gravatar do "#{record.google_username}@gmail.com" end ``` --- # Heading :::code-group ```ruby [Field id] field :user_information, as: :heading ``` ```ruby [Label] field :some_id, as: :heading, label: "user information" ``` ```ruby [Computed] field :some_id, as: :heading do "user information" end ``` ::: An Avo show-view card with two heading fields acting as section dividers: a 'User information' heading above First name, Last name and Email fields, then a 'Contact details' heading above Birthday and Membership fields. The `Heading` field displays a header that acts as a separation layer between different sections. `Heading` is not assigned to any column in the database and is only visible on the `Show`, `Edit` and `Create` views. :::warning Computed heading The computed fields are not rendered on form views, same with heading field, if computed syntax is used it will not be rendered on the form views. Use `label` in order to render it on **all** views. ::: ## Options --- # Hidden There are scenarios where in order to be able to submit a form, an input should be present but inaccessible to the user. An example of this might be where you want to set a field by default without the option to change, or see it. `Hidden` will render a `` element on the `Edit` and `New` page. > Hidden will only render on the `Edit` and `New` views. ### Example usage: ```ruby # Basic field :group_id, as: :hidden # With default field :user_id, as: :hidden, default: -> { current_user.id } # If the current_user is a admin # 1. Allow them to see and select a user. # 2. Remove the user_id field to prevent user_id it from overriding the user selection. # Otherwise set the user_id to the current user and hide the field. field :user, as: :belongs_to, visible: -> { context[:current_user].admin? } field :user_id, as: :hidden, default: -> { current_user.id }, visible: -> { !context[:current_user].admin? } ``` --- # ID The `id` field is used to show the record's id. By default, it's visible only on the `Index` and `Show` views. That is a good field to add the `link_to_record` option to make it a shortcut to the record `Show` page. ```ruby field :id, as: :id ``` ## Options --- # KeyValue The `KeyValue` field makes it easy to edit flat key-value pairs stored in `JSON` format in the database. ```ruby field :meta, as: :key_value ``` An Avo New (create) form card showing a KeyValue field with three editable key/value rows (environment: production, region: eu-west, tier: premium), each row with a drag handle and a delete button, plus an add-row button in the Key/Value header. ## Options ## Customizing the labels You can easily customize the labels displayed in the UI by mentioning custom values in `key_label`, `value_label`, `action_text`, and `delete_text` properties when defining the field. ```ruby field :meta, # The database field ID as: :key_value, # The field type. key_label: "Meta key", # Custom value for key header. Defaults to 'Key'. value_label: "Meta value", # Custom value for value header. Defaults to 'Value'. action_text: "New item", # Custom value for button to add a row. Defaults to 'Add'. delete_text: "Remove item" # Custom value for button to delete a row. Defaults to 'Delete'. ``` ## Enforce restrictions You can enforce some restrictions by removing the ability to edit the field's key or value by setting `disable_editing_keys` or `disable_editing_values` to `true` respectively. If `disable_editing_keys` is set to `true`, be aware that this option will also disable adding rows as well. You can separately remove the ability to add a new row by setting `disable_adding_rows` to `true`. Deletion of rows can be enforced by setting `disable_deleting_rows` to `true`. ```ruby field :meta, # The database field ID as: :key_value, # The field type. disable_editing_keys: false, # Option to disable the ability to edit keys. Implies disabling to add rows. Defaults to false. disable_editing_values: false, # Option to disable the ability to edit values. Defaults to false. disable_adding_rows: false, # Option to disable the ability to add rows. Defaults to false. disable_deleting_rows: false # Option to disable the ability to delete rows. Defaults to false. ``` Setting `disabled: true` enforces all restrictions by disabling editing keys, editing values, adding rows, and deleting rows collectively. ```ruby field :meta, # The database field ID as: :key_value, # The field type. disabled: true, # Option to disable editing keys, editing values, adding rows, and deleting rows. Defaults to false. ``` `KeyValue` is hidden on the `Index` view. --- # Lexxy ```ruby field :body, as: :lexxy ``` The `Lexxy` field renders [Lexxy](https://github.com/basecamp/lexxy), Basecamp's modern rich text editor for Action Text, built on Meta's Lexical framework. It produces clean HTML semantics, supports Markdown shortcuts, code syntax highlighting, and rich attachment previews. It supports [Action Text](https://guides.rubyonrails.org/action_text_overview.html) attributes and plain `string`/`text` columns storing HTML, with file attachments uploaded through [Active Storage](https://guides.rubyonrails.org/active_storage_overview.html) direct uploads. Lexxy field is hidden from the `Index` view. ## Requirements - Rails >= 8.0.2 (required by the `lexxy` gem) :::info Add this field to the `Gemfile` ```ruby # Gemfile gem "avo-lexxy_field" ``` ::: :::warning Lexxy takes over Action Text The `lexxy` gem replaces Trix as the default `form.rich_text_area` editor across your whole app. If you only want Lexxy inside Avo, opt out in the host app: ```ruby # config/application.rb (Rails 8.0/8.1) config.lexxy.override_action_text_defaults = false ``` ::: ## Options ## Action Text Lexxy is built for Action Text and works with it out of the box, including file attachments. ```ruby class Post < ApplicationRecord has_rich_text :body end ``` ```ruby field :body, as: :lexxy ``` ## Media Library When the Media Library is enabled, the editor's toolbar gets a button that opens the library in a modal. Picking an asset inserts it into the content β€” images as Action Text attachments, other files as links. The button is hidden when the field has attachments disabled (which is the default on plain `string`/`text` columns), since there is nothing to attach the blob to. ## Editor configuration Lexxy's element attributes (`markdown`, `rich-text`, `headings`, `permitted-attachment-types`, etc.) are documented in the [Lexxy docs](https://lexxy.dev/docs/) and can be configured globally through `Lexxy.configure` presets in your JavaScript, or per field through the field's `html` option. --- # Location The `Location` field is used to display a point on a map. ```ruby field :coordinates, as: :location ``` An Avo Map card on the Dashy dashboard showing an embedded Google Maps view of Manhattan with an Open in Maps link and map navigation controls. :::warning You need to add the `mapkick-rb` (not `mapkick`) gem to your `Gemfile` and have the `MAPBOX_ACCESS_TOKEN` environment variable with a valid [Mapbox](https://account.mapbox.com/auth/signup/) key. ::: ## Description By default, the location field is attached to one database column that has the coordinates in plain text with a comma `,` joining them (`latitude,longitude`). Ex: `44.427946,26.102451` Avo will take that value, split it by the comma and use the first element as the `latitude` and the second one as the `longitude`. On the view you'll get in interactive map and on the edit you'll get one field where you can edit the coordinates. ## Options --- # Markdown Typing Markdown in the Marksmith editor, then toggling the Preview tab to see the rendered HTML :::info In Avo 3.17 we renamed the `markdown` field `easy_mde` and introduced this custom one based on the [Marksmith editor](https://github.com/avo-hq/marksmith). Please read the docs on the repo for more information on how it works. ::: This field is inspired by the wonderful GitHub editor we all love and use. It supports applying styles to the markup, dropping files in the editor, and using the Media Library. The uploaded files will be taken over by Rails and persisted using Active Storage. ```ruby field :body, as: :markdown ``` :::warning Please ensure you have these gems in your `Gemfile`. ```ruby gem "marksmith" gem "commonmarker" ``` :::
## Supported features - [x] ActiveStorage file attachments - [x] Media Library integration - [x] Preview panel - [x] [Ready-to-use renderer](https://github.com/avo-hq/marksmith#built-in-preview-renderer) - [x] Text formatting - [x] Lists - [x] Links - [x] Images - [x] Tables - [x] Code blocks - [x] Headings ## Customize the renderer There are two places where we parse the markdown into the HTML you see. 1. In the controller 2. In the field component You may customize the renderer by overriding the model. ```ruby # app/models/marksmith/renderer.rb module Marksmith class Renderer def initialize(body:) @body = body end def render if Marksmith.configuration.parser == "commonmarker" render_commonmarker elsif Marksmith.configuration.parser == "kramdown" render_kramdown else render_redcarpet end end def render_commonmarker # commonmarker expects an utf-8 encoded string body = @body.to_s.dup.force_encoding("utf-8") Commonmarker.to_html(body) end def render_redcarpet ::Redcarpet::Markdown.new( ::Redcarpet::Render::HTML, tables: true, lax_spacing: true, fenced_code_blocks: true, space_after_headers: true, hard_wrap: true, autolink: true, strikethrough: true, underline: true, highlight: true, quote: true, with_toc_data: true ).render(@body) end def render_kramdown body = @body.to_s.dup.force_encoding("utf-8") Kramdown::Document.new(body).to_html end end end ``` ## Options --- # Money The `Money` field is used to display a monetary value. ```ruby field :price, as: :money, currencies: %w[EUR USD RON PEN] ``` ## Money Field Example You can explore the implementation of the money field in [avodemo](https://main.avodemo.com/avo/resources/products/new) and it's corresponding code on GitHub [here](https://github.com/avo-hq/main.avodemo.com/blob/main/app/avo/resources/product.rb) ### Example on new An Avo create-form card containing a money field: an amount input showing 0.00 beside a currency selector set to USD. ### Example on show An Avo show-view card using the standard description-list layout: Price RON showing 1,499.00 Lei and Price USD showing $199.00 on separate rows with label left and value right. ### Example on index An Avo index table with three columns β€” ID, Title and Price β€” where the Price column shows each product's money field formatted as a currency value such as $199.00. ## Installation This field is a standalone gem. You have to add it to your `Gemfile` alongside the `money-rails` gem. :::info Add this field to the `Gemfile` ```ruby # Gemfile gem "avo-money_field" gem "money-rails", "~> 1.12" ``` ::: :::warning Important: Monetization Requirement In order to fully utilize the money field's features, you must monetize the associated attribute at the model level using the `monetize` method from the `money-rails` gem. ([Usage example](https://github.com/RubyMoney/money-rails?tab=readme-ov-file#usage-example)) For example: ```ruby monetize :price_cents ``` Without this step, the money field may not behave as expected, and the field might not render. ::: ## Options --- # Number The `number` field renders a `input[type="number"]` element. ```ruby field :age, as: :number ``` ## Options ## Examples ```ruby field :age, as: :number, min: 0, max: 120, step: 5 ``` --- # Password The `Password` field renders a `input[type="password"]` element for that field. By default, it's visible only on the `Edit` and `New` views. ```ruby field :password, as: :password ``` ## Options ## Related - Devise password optional --- # Preview The `Preview` field adds a tiny icon to each row on the view that, when hovered, it will display a preview popup with more information regarding that record. An Avo Teams index table where hovering the preview icon on a row opens a popup showing that record's preview fields. ```ruby field :preview, as: :preview ``` ## Define the fields The fields shown in the preview popup are configured similarly to how you configure the visibility in the different views. When you want to display a field in the preview popup simply call the `show_on: :preview` option on the field. ```ruby{4,8,12,15} # app/avo/resources/team.rb class Avo::Resources::Team < Avo::BaseResource def fields field :preview, as: :preview field :name, as: :text, sortable: true, show_on: :preview field :color, as: Avo::Fields::ColorPickerField, hide_on: :index, show_on: :preview field :description, as: :textarea, show_on: :preview end end ``` Record preview popover open over the Team index table, triggered from a row ## Authorization The preview request authorization is controlled with the `preview?` policy method. --- # Progress bar The `ProgressBar` field renders a `progress` element on `Index` and `Show` views and a `input[type=range]` element on `Edit` and `New` views. ```ruby field :progress, as: :progress_bar ``` An Avo index table with ID, Name and Progress columns where each row shows a progress bar with its percentage value. ## Options ## Examples ```ruby field :progress, as: :progress_bar, max: 150, step: 10, display_value: true, value_suffix: "%" ``` An Avo edit-form card containing a progress_bar field showing the value above a range slider configured with max 150, step 10 and a percent suffix. --- # Radio An Avo edit-form card with a radio field labelled User role, animating through Administrator, Manager and Writer options. The `Radio` field is used to render radio buttons. It's useful when only one value can be selected in a given options group. ### Field declaration example Below is an example of declaring a `radio` field for a role: ```ruby field :role, as: :radio, name: "User role", options: { admin: "Administrator", manager: "Manager", writer: "Writer" } ``` ## Options --- # Record link Sometimes you just need to link to a field. That's it! This is what this field does. You give it a record and it will link to it. That record can come off an association a method or any kind of property on the record instance. :::info Add this field to the `Gemfile` ```ruby # Gemfile gem "avo-record_link_field" ``` ::: :::warning That record you're pointing to should have a resource configured. ::: ```ruby{14,19} class Comment < ApplicationRecord # Your model must return an instance of a record has_one :post # or belongs_to :post # or def post # trivially find a post Post.find 42 end end # Calling the method like so will give us an instance of a Post Comment.first.post => # class Avo::Resources::Comment < Avo::BaseResource def fields # This will run `record.post` and try to display whatever is returned. field :post, as: :record_link end end ``` An Avo show-view card containing a record_link field that displays a clickable link to the related Post record. ## Options Besides some of the default options, there are a few custom ones. ## Using computed values Of course you can take full control of this field and use your computed values too. In order to do that, open a block and run some ruby query to return an instance of a record. #### Example ```ruby field :post, as: :record_link do # This will generate a link similar to this # https://example.com/avo/resources/posts/42 Post.find 42 end # or field :creator, as: :record_link, add_via_params: false do user_id = SomeService.new(comment: record).fetch_user_id # returns 31 # This will generate a link similar to this # https://example.com/avo/resources/users/31 User.find user_id end # or field :creator, as: :record_link, use_resource: "AdminUser", add_via_params: false do user_id = SomeService.new(comment: record).fetch_user_id # returns 31 # This will generate a link similar to this # https://example.com/avo/resources/admin_users/31 User.find user_id end ``` --- # Rhino An Avo edit-form card with a Rhino WYSIWYG editor showing the formatting toolbar above a text area with sample content. The wonderful [Rhino Editor](https://rhino-editor.vercel.app/) built by [Konnor Rogers](https://www.konnorrogers.com/) is available and fully integrated with Avo. :::info Add this field to the `Gemfile` ```ruby # Gemfile gem "avo-rhino_field" ``` ::: ```ruby field :body, as: :rhino ``` Rhino is based on [TipTap](https://tiptap.dev/) which is a powerful and flexible WYSIWYG editor. It supports [ActiveStorage](https://guides.rubyonrails.org/active_storage_overview.html) file attachments, [ActionText](https://guides.rubyonrails.org/action_text_overview.html), and seamlessly integrates with the Media Library. ## Options --- # Select The `Select` field renders a `select` field. ```ruby field :type, as: :select, options: { 'Large container': :large, 'Medium container': :medium, 'Tiny container': :tiny }, display_value: true, placeholder: 'Choose the type of the container.' ``` ## Options ## Customization You may customize the `Text` field with as many options as you need. ```ruby field :title, # The database field ID as: :text, # The field type name: 'Post title', # The label you want displayed required: true, # Display it as required readonly: true, # Display it disabled as_html: true, # Should the output be parsed as html placeholder: 'My shiny new post', # Update the placeholder text format_using: -> { value.truncate 3 } # Format the output ``` --- # Textarea The `textarea` field renders a `