# CM Admin (Catalyst) > Ruby on Rails admin panel gem. These docs also ship inside the gem: run `bundle show cm-admin` and read `docs-site/docs/` for docs matching the installed version. This file contains all documentation content in a single document following the llmstxt.org standard. ## CM Admin An admin gem for Ruby on Rails application. Get your admin panel setup running quickly. ### Installation Add this line to your application's Gemfile: ```ruby gem 'cm-admin' ``` And then execute: `bundle install` Or install it yourself as: `gem install cm-admin` ### Usage Install the gem `rails g cm_admin:install` ### Demo For demo check [here](http://cm-admin.labs.commutatus.com) For demo repo check [here](https://github.com/commutatus/cm-admin-panel-demo) ### Development For local development setup and connecting `cm-admin` to other local Rails apps, check the [Catalyst Setup Guide](/docs/catalyst-setup). After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. Any markdown inside `docs-site/docs/` folder will be auto added in CM Docs. ### Deployment To release a new version of the Gem you can use Github Actions. Go to Actions tab in your repository and click on `Bump Gem` workflow. You will see `Run workflow` button click on it and choose `Bump Type`, then click `Run Workflow` and it will bump the version of the gem and push the changes to the repository. ### Docs for AI Coding Agents These docs are available in two agent-friendly forms: - **Bundled with the gem.** The Markdown source of these docs ships inside `cm-admin`, so they always match the installed version. Find them with: ```bash echo "$(bundle show cm-admin)/docs-site/docs" ``` - **On the website.** [`/llms.txt`](https://docs.cm-admin.commutatus.com/llms.txt) indexes every page, [`/llms-full.txt`](https://docs.cm-admin.commutatus.com/llms-full.txt) contains all docs in one file, and each page has a Markdown version at its URL plus `.md` (for example, [`/docs/Views/IndexDsl.md`](https://docs.cm-admin.commutatus.com/docs/Views/IndexDsl.md)). Prefer the bundled docs: they need no network access and match the version in your `Gemfile.lock`. ### Contributing Bug reports and pull requests are welcome on [GitHub](https://github.com/commutatus/cm-admin). ### License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). --- ## 2. Views Basic views explained here --- ## DestroyDsl ### `cm_destroy` Create a form for the destroy action. #### Parameters | Name | Type | Description | | ------------------- | ------------------ | ------------------------------------------------------ | | `display_name` | `String` | The display/button name of the page. | | `page_title` | `String \| Symbol` | The title of the page, symbol will use a model method. | | `page_description` | `String` | Description of the page. | | `confirmation_text` | `String` | Confirmation text to show in the modal. | | `partial` | `String` | Partial path of the page. | | `redirect_to` | `Proc, nil` | Lambda to redirect after destroy. | | `display_if` | `Proc` | Lambda to conditionally display the form. | #### Example — Destroying page with redirect ```ruby cm_destroy(display_name: 'Delete Post', page_title: "Delete Post", page_description: 'Are you sure you want to delete this Post?', confirmation_text: 'Delete', redirect_to: ->(current_object) { "/pages" }) ``` #### Method Signature ```ruby cm_destroy(display_name: 'Delete', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, confirmation_text: 'Delete', display_if: ->(_arg) { true }) ``` --- --- ## Module: `DSL Methods` ## Module: `DSL Methods` Defined in `lib/cm_admin/models/dsl_method.rb` ### Instance Method Summary | Method | Description | | ----------------------------------------------------- | ------------------------------------------------------------- | | [`action_group`](#action_group) | Create a new group for actions. | | [`alert_box`](#alert_box) | Adds a new alert to the current section. | | [`all_db_columns`](#all_db_columns) | Get all DB columns for index layout automatically. | | [`bulk_action`](#bulk_action) | Define a bulk action for selected records. | | [`cm_edit`](#cm_edit) | Create the edit form page. | | [`cm_index`](#cm_index) | Create index/list page with pagination. | | [`cm_new`](#cm_new) | Create the “new/create” form page. | | [`cm_destroy`](#cm_destroy) | Create the “destroy” form page. | | [`cm_section`](#cm_section) | Define a layout section for forms/pages. | | [`cm_show`](#cm_show) | Create the detail/show page. | | [`cm_show_section`](#cm_show_section-deprecated) | **⚠️ Deprecated** — use `cm_section` instead. | | [`cm_dashboard`](#cm_dashboard) | Create the dashboard page with widgets, filters, and widgets. | | [`column`](#column) | Create a new column for index layout. | | [`custom_action`](#custom_action) | Define a custom action beyond CRUD. | | [`eager_load_associations`](#eager_load_associations) | Preload associations to avoid N+1 queries. | | [`relationship_type`](#tab) | Specify `has_many` or `has_one` relationship for a tab. | | [`field`](#field) | Add a field to the show page. | | [`filter`](#filter) | Add filters to index/list page. | | [`form_field`](#form_field) | Add a field to the new or edit form. | | [`title_icon`](#title_icon) | Add an icon button with link and tooltip next to the title. | | [`kanban_view`](#kanban_view) | Show index page as Kanban board view. | | [`page_description`](#page_description) | Set page description. | | [`page_title`](#page_title) | Set page title. | | [`row`](#row) | Add a row wrapper for layout. | | [`scope_list`](#scope_list) | Add scopes for index action. | | [`sort`](#sort) | Configure sortable columns (preferred alternative). | | [`tab`](#tab) | Add tabs on show page, usually for associations. | ### `cm_index` Create a table view for the index page with pagination. #### Parameters | Name | Type | Description | | ------------------ | -------- | ---------------------------------------------------------------------------------------- | | `display_name` | `String` | The display name of the page. | | `page_title` | `String` | The title of the page. If not provided, defaults to the formatted model name pluralized. | | `page_description` | `String` | The description of the page. | | `partial` | `String` | The partial path to render for the page. | | `view_type` | `Symbol` | The type of index view. Allowed values: `:table`, `:card`, `:kanban`. Default: `:table`. | --- #### Example — Basic index page ```ruby cm_index do page_title 'Post' column :title column :created_at, field_type: :date, format: '%d %b, %Y' column :updated_at, field_type: :date, format: '%d %b, %Y', header: 'Last Updated At' end ``` --- #### Display Types Supported | View Type | Usage | | --------- | ------------------------------------------------ | | `:table` | Default table layout with pagination. | | `:card` | Card-style layout (good for media-rich models). | | `:kanban` | Kanban-style board grouped by a status or field. | --- #### Notes - `cm_index` must wrap all columns and filters meant for the index view. - If no `page_title` is given, it automatically becomes the pluralized resource name. - To build a Kanban index, use `view_type: :kanban` together with a `kanban_view` declaration. --- #### Method Signature ```ruby cm_index(display_name: nil, page_title: nil, page_description: nil, partial: nil, view_type: :table) ``` ### `cm_show` Create a view for the show page. #### Parameters | Name | Type | Description | | ------------------ | ------------------ | ------------------------------------------------------------------------------------ | | `display_name` | `String` | The display name of the page. | | `page_title` | `String \| Symbol` | The title of the page. If a symbol is passed, it will be a method name on the model. | | `page_description` | `String` | The description of the page. | | `partial` | `String` | The partial path to render for the page. | #### Example — Showing page ```ruby cm_show page_title: :title do tab :profile, '' do cm_section 'Post Details' do field :title field :body, field_type: :rich_text field :is_featured field :status, field_type: :badge, badge_class: STATUS_TAG_COLOR end end end ``` #### Method Signature ```ruby cm_show(display_name: nil, page_title: nil, page_description: nil, partial: nil) ``` --- ### `cm_edit` Create a form for the edit action. #### Parameters | Name | Type | Description | | ------------------ | ------------------ | ------------------------------------------------------ | | `display_name` | `String` | The display/button name of the page. | | `page_title` | `String \| Symbol` | The title of the page, symbol will use a model method. | | `page_description` | `String` | Description of the page. | | `partial` | `String` | Partial path of the page. | | `redirect_to` | `Proc, nil` | Lambda to redirect after update. | | `display_if` | `Proc` | Lambda to conditionally display the form. | #### Example — Editing page with redirect ```ruby cm_edit(display_name: 'Edit Post', page_title: "Edit Post", page_description: 'Enter all details to edit Post', redirect_to: ->(current_object) { "/pages/#{current_object.id}" }) do cm_section 'Details' do form_field :title, input_type: :string form_field :body, input_type: :rich_text end end ``` #### Method Signature ```ruby cm_edit(display_name: 'Edit', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, display_if: ->(_arg) { true }) ``` --- ### `cm_new` Create a form for the new action. #### Parameters | Name | Type | Description | | --------------------- | ----------- | -------------------------------------------------- | | `display_name` | `String` | The display/button name of the page. | | `page_title` | `String` | The title of the page. | | `page_description` | `String` | Description of the page. | | `partial` | `String` | Partial path of the page. | | `redirect_to` | `Proc, nil` | Lambda to redirect after create. | | `allow_create_action` | `Proc` | Lambda to control whether the Add button is shown. | #### Example — Creating a new page with redirect ```ruby cm_new(display_name: 'Add Post', page_title: "Add Post", page_description: 'Enter all details to add Post', redirect_to: ->(current_object) { "/pages/#{current_object.id}" }) do cm_section 'Details' do form_field :title, input_type: :string form_field :body, input_type: :rich_text end end ``` #### Method Signature ```ruby cm_new(display_name: 'Add', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, allow_create_action: -> { true }) ``` --- ### `cm_destroy` Create a form for the destroy action. #### Parameters | Name | Type | Description | | ------------------- | ------------------ | ------------------------------------------------------ | | `display_name` | `String` | The display/button name of the page. | | `page_title` | `String \| Symbol` | The title of the page, symbol will use a model method. | | `page_description` | `String` | Description of the page. | | `confirmation_text` | `String` | Confirmation text to show in the modal. | | `partial` | `String` | Partial path of the page. | | `redirect_to` | `Proc, nil` | Lambda to redirect after destroy. | | `display_if` | `Proc` | Lambda to conditionally display the form. | #### Example — Destroying page with redirect ```ruby cm_destroy(display_name: 'Delete Post', page_title: "Delete Post", page_description: 'Are you sure you want to delete this Post?', confirmation_text: 'Delete', redirect_to: ->(current_object) { "/pages" }) ``` #### Method Signature ```ruby cm_destroy(display_name: 'Delete', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, confirmation_text: 'Delete', display_if: ->(_arg) { true }) ``` --- ### `page_title` Set page title for the current action. | Name | Type | Description | | ------- | -------- | ------------------ | | `title` | `String` | Title of the page. | #### Method Signature ```ruby page_title(title) ``` --- ### `page_description` Set page description for the current action. Rendered below the page title in the header on every action that displays the top navbar. The description supports rich text (HTML) — text formatting, lists and hyperlinks are allowed. Media elements (images, videos, iframes) are stripped by sanitization. Links open in a new tab automatically. Long descriptions are truncated to two lines with a **View more / View less** toggle. | Name | Type | Description | | ------------- | -------- | ------------------------ | | `description` | `String` | Description of the page (HTML allowed). | #### Method Signature ```ruby page_description(description) ``` #### Example ```ruby cm_index do page_description '

This is an optional rich text field and its content is truncated after two lines to keep the layout clean and concise.

' end ``` --- ### `title_icon` Configure an optional icon button rendered next to the page title in the header. The icon links to a URL (e.g. a video or document) and shows a tooltip on hover. The button renders only when both `icon_name` and `url` are provided. | Name | Type | Description | | ------------ | -------- | ------------------------------------------------------------------------ | | `icon_name` | `String` | Icon class for the button (e.g. `fa-solid fa-circle-info`). | | `url` | `String` | URL the icon button links to (opens in a new tab). | | `tooltip` | `String` | Optional tooltip text describing the button's purpose. | | `display_if` | `Proc` | Optional lambda controlling the button's visibility. | #### Method Signature ```ruby title_icon(icon_name: nil, url: nil, tooltip: nil, display_if: nil) ``` #### Example ```ruby cm_index do title_icon icon_name: 'fa-solid fa-circle-info', url: 'https://example.com/tutorial', tooltip: 'Click to view tutorial' end ``` --- ### `kanban_view` Set Kanban view for the current action. | Name | Type | Description | | ------------- | -------- | -------------------------- | | `column_name` | `String` | Name of the Kanban column. | | `exclude` | `Array` | Fields to exclude. | | `only` | `Array` | Fields to include. | #### Method Signature ```ruby kanban_view(column_name, exclude: [], only: []) ``` --- ### `scope_list` Set scopes for the current action. | Name | Type | Description | | -------- | ------- | ---------------- | | `scopes` | `Array` | Array of scopes. | #### Method Signature ```ruby scope_list(scopes = []) ``` --- ### `tab` Create a new tab on the show page. #### Parameters | Name | Type | Description | | ------------------------- | ------------------ | ------------------------------------------------------------------------------------ | | `tab_name` | `String \| Symbol` | Name of the tab. | | `action_name` | `String` | Name of the action. | | `associated_model` | `String` | Associated model name. | | `layout_type` | `String` | Layout type: `cm_association_index` or `cm_association_show`. | | `layout` | `String` | Layout of the tab. | | `partial` | `String` | Partial path of the tab. | | `display_if` | `Proc` | Lambda returning boolean to display tab. | | `view_type` | `Symbol` | View type: `:table` or `:card`. | | `show_count` | `Boolean` | Whether to show record count. Default: `true`. | | `associated_model_name` | `String` | Name of associated model if different from class name. | | `allow_create_action` | `Proc` | Lambda to control whether the Add button is shown. | | `eager_load_associations` | `Array` | Associations to eager load to avoid N+1 queries. | | `relationship_type` | `Symbol` | Type of relationship. Allowed values: `:has_many`, `:has_one`. Default: `:has_many`. | #### Example — Tab with `has_many` relationship ```ruby tab :comments, 'comment', associated_model: 'comments', layout_type: 'cm_association_index', show_count: true do column :message end ``` #### Example — Tab with `has_one` relationship ```ruby tab :profile, 'profile', associated_model: 'profile', layout_type: 'cm_association_show', relationship_type: :has_one, show_count: false ``` #### Method Signature ```ruby tab(tab_name, action_name, associated_model: nil, layout_type: nil, layout: nil, partial: nil, display_if: nil, show_count: true, view_type: nil, associated_model_name: nil, allow_create_action: -> { true }, eager_load_associations: nil, relationship_type: :has_many, &block) ``` #### Configuration After creating a tab, make sure to update the permissions for the respective role in the Permissions tab. --- ### `row` Create a new row on a page or form. | Name | Type | Description | | ------------ | ------ | ---------------------------------------- | | `display_if` | `Proc` | Lambda returning boolean to display row. | | `html_attrs` | `Hash` | HTML attributes for the row. | #### Example — Conditional Row with Section ```ruby row(display_if: ->(current_object) { current_object.name == 'John' }, html_attrs: { class: 'row-class' }) do cm_section 'Details' do form_field :title, input_type: :string end end ``` #### Method Signature ```ruby row(display_if: nil, html_attrs: nil, &block) ``` --- ### `cm_section` Create a new section on a page or form. | Name | Type | Description | | ---------------- | --------- | -------------------------------------------- | | `section_name` | `String` | Name of section. | | `display_if` | `Proc` | Lambda returning boolean to display section. | | `col_size` | `Integer` | Column size in grid layout. | | `html_attrs` | `Hash` | HTML attributes for the section. | | `partial` | `String` | Partial path. | | `dynamic_fields` | Any | Dynamic fields. | #### Example: Creating a section ```ruby cm_section('Basic Information', display_if: ->(current_object) { current_object.name == 'John' }, col_size: 6, html_attrs: { class: 'section-class' }) do field :title, input_type: :string end ``` #### Method Signature ```ruby cm_section(section_name, display_if: nil, col_size: nil, html_attrs: nil, partial: nil, dynamic_fields: nil, &block) ``` --- ### `cm_show_section` **(Deprecated)** Use `cm_section` instead. --- ### `cm_dashboard` Create a dashboard page with widgets, filters, and sortable columns. #### Parameters | Name | Type | Description | | ------------------ | -------- | ---------------------------------------- | | `name` | `Symbol` | The unique name of the dashboard action. | | `display_name` | `String` | The display name of the page. | | `icon_name` | `String` | The icon name for the dashboard link. | | `page_title` | `String` | The title of the page. | | `page_description` | `String` | The description of the page. | | `partial` | `String` | The partial path to render for the page. | #### Example — Single dashboard page ```ruby cm_dashboard :my_dashboard, display_name: 'User Dashboard' do page_title 'User Dashboard' filter %i[email first_name], :search, placeholder: 'Search' sort column: 'updated_at', display_name: 'Last Updated At' sort column: 'created_at', display_name: 'Created At', default: true, default_direction: 'desc' row do cm_section 'Charts' do chart :chart_data, chart_type: :pie_chart end end end ``` #### Method Signature ```ruby cm_dashboard(name, display_name: nil, icon_name: nil, page_title: nil, page_description: nil, partial: nil) ``` --- ### `column` Create a new column for index layout. #### Parameters | Name | Type | Description | | ----------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `field_name` | `String` | The name of the field. | | `field_type` | `Symbol` | The type of field. Can be `:association`, `:attachment`, `:badge`, `:boolean`, `:color_picker`, `:custom`, `:date`, `:datetime`, `:decimal`, `:drawer`, `:enum`, `:image`, `:integer`, `:link`, `:link_to_email`, `:link_to_phone`, `:money`, `:money_with_symbol`, `:rich_text`, `:string`, or `:text`. | | `header` | `String` | The header of the field. | | `format` | `String` | The format of the field for date fields. | | `prefix` | `String` | Prefix to display before the field value. | | `suffix` | `String` | Suffix to display after the field value. | | `exportable` | `Boolean` | Whether the column is exportable. Default is `true`. | | `round` | `Integer` | Number of decimal places to round numeric values to. | | `height` | `Integer` | The height of the field for image fields. | | `width` | `Integer` | The width of the field for image fields. | | `cm_css_class` | `String` | CSS class to apply to the column. | | `link` | `Boolean` | Whether to render the field as a link. | | `url` | `String` | Custom URL for the link field. | | `link_text` | `String` | Custom text to display for the link. | | `custom_method` | `Symbol` | Custom method to call on the record for the field value. | | `helper_method` | `Symbol` | The helper method for the field. Should be defined in `custom_helper.rb` and will take two arguments: `record` and `field_name`. | | `export_method` | `Symbol` | The export method for the field. Should be defined in the model and will take no arguments. | | `managable` | `Boolean` | Whether the column is managable (can be shown/hidden by the user). Default is `true`. | | `lockable` | `Boolean` | Whether the column is lockable (always visible). Default is `false`. | | `viewable` | `Boolean` | Whether the column is viewable. Default is `true`. | | `drawer_partial` | `String` | Partial path for drawer field type. | | `badge_class` | `Hash` | Badge classes for the field. Values must be `cm-badge-*` classes (see [Status Tags](../3.%20Features/status-tags.md)). Example: `{ approved: 'cm-badge-green', draft: 'cm-badge-yellow', rejected: 'cm-badge-red' }`. | | `badge_helper_texts` | `Hash` | Helper texts for badge fields, shown as a tooltip icon next to the badge. Example: `{ approved: 'Reviewed by admin', rejected: 'Check rejection reason' }`. Default: `{}`. | | `display_if` | `Proc` | A lambda that takes the current object and returns true or false. | | `association_name` | `Symbol` | Name of the association for `field_type: :association`. | | `association_type` | `Symbol` | Type of the association (`:belongs_to`, `:has_one`, `:has_many`). | | `add_to_filter` | `Boolean` | Whether to add this column to filters. Default is `true`. | | `card_image` | `Boolean` | Whether this column is the card image for card/kanban view. Default is `false`. | | `card_title` | `Boolean` | Whether this column is the card title for card/kanban view. When no column is marked as the title, the first column is used as the title. Default is `false`. | | `card_footer` | `Boolean` | Whether this column is the card footer for card/kanban view. Default is `false`. | | `no_wrap` | `Boolean` | Whether to wrap the content of the field or not. Default is `false`. | | `display_as_local_time` | `Boolean` | Whether to display datetime fields in the user's local time using the `local_time` helper. When `false`, the datetime is formatted server-side using `strftime`. Default: `true`. | | `timezone` | `String`, `Symbol`, `Proc` | Timezone to use when displaying `:datetime` fields. A `String` is used as a timezone name (e.g. `'Asia/Kolkata'`), a `Symbol` calls a helper method from `custom_helper.rb` with `(record, field_name)`, and a `Proc` receives the record and returns the timezone string. Overrides `display_as_local_time`. | #### Example ```ruby column('name', field_type: :string) ``` #### Method Signature ```ruby column(field_name, options = {}) ``` --- ### `all_db_columns` Get all database columns for a model for index layout. | Name | Type | Description | | --------- | ------ | ----------------------- | | `options` | `Hash` | Options like `exclude`. | #### Example ```ruby all_db_columns(exclude: ['id']) ``` #### Method Signature ```ruby all_db_columns(options = {}) ``` --- ### `action_group` Create a new group for actions. | Name | Type | Description | | ------------ | -------- | ------------------- | | `name` | `String` | Group name. | | `icon_name` | `String` | FontAwesome icon. | | `icon_style` | `String` | CSS style for icon. | #### Example ```ruby action_group 'Basic Information', icon_name: 'fa fa-list', icon_style: 'color: #000000' do custom_action 'Edit', verb: 'get', display_type: :button, icon_name: 'fa fa-edit', icon_style: 'color: #000000' do @posts = Post.all end end ``` #### Method Signature ```ruby action_group(name, icon_name: 'fa fa-list', icon_style: nil) ``` --- ### `custom_action` Create a new custom action for model. #### Parameters | Name | Type | Description | | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `name` | `String` | The name of the action. | | `page_title` | `String` | The title of the page. | | `page_description` | `String` | The description of the page. | | `display_name` | `String` | The display name of the action. | | `verb` | `String` | The HTTP verb of the action. Can be `get`, `post`, `put`, `patch`, or `delete`. | | `layout` | `String` | The layout of the action. | | `layout_type` | `String` | The layout type of the action. Can be `cm_association_index` or `cm_association_show`. | | `partial` | `String` | The partial path of the action. | | `path` | `String` | The path of the action. | | `display_type` | `Symbol` | The display type of the action. Can be `:button`, `:modal`, `:form_modal`, `:form_page`, `:page`, or `:icon_only`. | | `modal_configuration` | `Hash` | The configuration of the modal. | | `url_params` | `Hash` | The URL parameters of the action. | | `display_if` | `Proc` | A lambda that takes the current object and returns true or false. | | `route_type` | `String` | The route type of the action. Can be `member` or `collection`. | | `icon_name` | `String` | The icon name of the action, following Font Awesome icon names. | | `icon_style` | `String` | The icon style of the action, following Font Awesome style. Example: `"--fa-primary-color: #fecb3e; --fa-secondary-color: #e63b7a;"` | | `group_name` | `String` | The group name of the action. All actions with the same group name will be grouped together in a dropdown. | | `alert_type` | `Symbol` | The type of success alert to show. Choices are `:flash` and `:banner`. Only applies to success messages. | #### Configuration After creating a custom action, make sure to update the permissions for the respective role in the Permissions tab. #### Examples 1. Creating a custom action with modal ```ruby custom_action name: 'approve', route_type: 'member', verb: 'patch', icon_name: 'fa-regular fa-circle-check', path: ':id/approve', display_type: :modal, display_if: lambda(&:draft?), modal_configuration: { title: 'Approve Post', description: 'Are you sure you want approve this post', confirmation_text: 'Approve' } do post = ::Post.find(params[:id]) post.approved! post end ``` 2. Creating a custom action with button ```ruby custom_action name: 'approve', route_type: 'member', verb: 'patch', icon_name: 'fa-regular fa-circle-check', path: ':id/approve', display_type: :button, display_if: lambda(&:draft?), group_name: 'my action' do post = ::Post.find(params[:id]) post.approved! post end ``` 3. Creating a custom action with form modal and form page ```ruby custom_action name: 'assignee_update', display_name: 'Assignee', verb: 'post', route_type: 'member', icon_name: 'fa-regular fa-user', display_type: :form_modal, path: ':id/assignee_update', modal_configuration: { title: 'Select Assignees', confirmation_text: 'Submit' } do form do cm_section '' do alert_box body: "An email notification will be sent to each assignee once you confirm.", type: :info form_field :assignee_ids, input_type: :multi_select, helper_method: :select_options_for_active_employee, label: 'Assignees' end end on_submit do @task = ::Task.find(params[:id]) @task.update!(assignee_ids: params.dig(:task, :assignee_ids)) @task end end ``` #### Method Signature ```ruby custom_action(name: nil, page_title: nil, page_description: nil, display_name: nil, verb: nil, layout: nil, layout_type: nil, partial: nil, path: nil, display_type: nil, redirect_to: nil, modal_configuration: {}, url_params: {}, display_if: ->(_) { true }, route_type: nil, icon_name: 'fa fa-th-large', icon_style: nil, group_name: nil, success_message: ->(_) { nil }, error_message: ->(_, _) { nil }, alert_type: :flash, &block) ``` --- ### `bulk_action` Create a new bulk action for a model. #### Parameters | Name | Type | Description | | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | | `name` | `String` | The name of the action. | | `display_name` | `String` | The display name of the action. | | `display_if` | `Proc` | A lambda that takes the current object and returns true or false. | | `redirection_url` | `String` | The redirection URL of the action. | | `icon_name` | `String` | The icon name of the action, following Font Awesome icon names. | | `verb` | `String` | The HTTP verb of the action. Can be `get`, `post`, `put`, `patch`, or `delete`. | | `display_type` | `Symbol` | The display type of the action. Can be `:page`, `:modal`, `:form_modal`, `:button`, or `:icon_only` (deprecated). | | `modal_configuration` | `Hash` | The configuration of the modal. | | `route_type` | `String` | The route type of the action. Can be `member` or `collection`. | | `partial` | `String` | The partial path of the action. | | `execution_mode` | `Symbol` | The execution mode of the action. Can be `:bulk` or `:individual` (default `:individual`). | | `alert_type` | `Symbol` | The type of success alert to show. Choices are `:flash` and `:banner`. Only applies to success messages. | - Supports `:individual` or `:bulk` execution mode and `form_modal` for custom forms. #### Examples 1. Creating a bulk action ```ruby bulk_action name: 'approve', display_name: 'Approve', display_if: lambda { |arg| arg.draft? }, redirection_url: '/posts', icon_name: 'fa-regular fa-circle-check', verb: :patch, display_type: :modal, modal_configuration: { title: 'Approve Post', description: 'Are you sure you want approve this post', confirmation_text: 'Approve' }, execution_mode: :individual do posts = ::Post.where(id: params[:ids]) posts.each(&:approved!) posts end ``` 2. Creating a bulk action with form ```ruby bulk_action name: 'bulk_status_update', display_name: 'Status', icon_name: 'fa-light fa-arrow-progress', display_type: :form_modal, execution_mode: :bulk, modal_configuration: { title: 'Select Status', confirmation_text: 'Submit' } do form do cm_section '' do alert_box body: "You’re updating the status for multiple tasks. The selected status will be applied to all chosen tasks.", type: :warning form_field :status, input_type: :single_select, is_required: true, helper_method: :select_options_for_task_user_status end end on_submit do |ids| @tasks = ::Task.where(id: ids) @tasks.update!(status: params.dig(:task, :status)) @tasks end end ``` #### Method Signature ```ruby bulk_action(name: nil, display_name: nil, display_if: ->(_) { true }, redirection_url: nil, icon_name: nil, verb: nil, display_type: nil, modal_configuration: {}, route_type: nil, partial: nil, icon_style: nil, execution_mode: :individual, success_message: ->(_arg) { nil }, error_message: ->(_arg) { nil }, alert_type: :banner, &block) ``` --- ### `filter` Create a new filter for a model. #### Parameters | Name | Type | Description | | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `db_column_name` | `String` | The name of the database column. | | `filter_type` | `String` | The type of filter. Can be `:date`, `:multi_select`, `:range`, `:search`, `:single_select`, or `:string`. | | `placeholder` | `String` | The placeholder text for the filter. | | `helper_method` | `String` | The helper method for the filter. Should be defined in `custom_helper.rb`. | | `filter_with` | `Symbol` | Filter using the scope name on the model. | | `active_by_default` | `Boolean` | Make the filter active by default. | | `collection` | `Array` | The collection of values for the filter. Use with `single_select` or `multi_select`. | | `default_value` | `Proc` | A lambda providing the default value for the filter when no filter parameters are provided. | | `allow_select_all_option` | `Boolean` | Shows a `(Select All)` option in `multi_select` filters that checks/unchecks every option (including `(Empty)`). It is UI-only: the selected option values are sent, never `cm_select_all`. Default: `true`. | #### Examples 1. Creating a filter ```ruby filter('name', :search) filter('created_at', :date) filter('status', :single_select, collection: ['draft', 'published']) filter('status', :multi_select, helper_method: 'status_collection') filter('age', :range) filter('status', :multi_select, collection: ['draft', 'published'], allow_select_all_option: true) filter('email', :string) ``` 2. Creating a filter with default value lambda ```ruby filter('status', :single_select, collection: ['draft', 'published'], default_value: -> { 'draft' }) filter('category', :multi_select, collection: ['A', 'B', 'C'], default_value: -> { ['A', 'B'] }) filter('created_at', :date, default_value: -> { "#{30.days.ago.to_date} to #{Date.today}" }) filter('user_id', :single_select, helper_method: 'user_collection', default_value: -> { Current.user.id }) ``` #### Method Signature ```ruby filter(db_column_name, filter_type = nil, options = {}) ``` --- ### `sort_direction` Note: ⚠️ This is a deprecated method. Set sort direction for filters. Use `sort` instead. --- ### `sort_column` Note: ⚠️ This is a deprecated method. Set sort column for filters. Use `sort` instead. --- ### `alert_box` Add an alert to the current section. | Name | Type | Description | | ---------------- | -------- | ------------------------------------------------------ | | `header` | `String` | Alert header. | | `body` | `String` | Alert body. | | `type` | `Symbol` | Type: `:info`, `:success`, `:danger`, `:warning`. | | `partial` | `String` | Custom partial path. | | `partial_locals` | `Hash` | Local variables to pass to the partial. Default: `{}`. | | `display_if` | `Proc` | Lambda to conditionally display alert. | | `html_attrs` | `Hash` | Custom HTML attributes. | #### Examples 1. Basic info alert ```ruby alert_box(header: "Information", body: "This is an informational message.", type: :info) ``` 2. Basic info alert with custom body ```ruby alert_box(header: "Information", body: "This is an informational message.
This is a break text", type: :info) ``` 3. Alert with custom partial ```ruby alert_box(partial: "/users/sessions/alert", display_if: ->(arg) { arg.present? }) ``` 4. Alert with partial and locals ```ruby alert_box(partial: "cm_admin/main/default_import_notes", partial_locals: { sample_file_path: '/sample.csv' }) ``` 5. Alert with conditional display and custom HTML attributes ```ruby alert_box(header: "Warning", body: "Please review your submission.", type: :warning, display_if: ->(user) { user.submissions.any?(&:incomplete?) }, html_attrs: { id: "submission-warning", data: { turbo_frame: "warnings" } } ) ``` **NOTE:** Alerts cannot be placed inside nested sections. If added within a nested section, the alert will appear on the wrapped cm_show_section. **NOTE:** Only the specified types (info, success, danger, warning) are supported. Any other type will default to a standard div. [Adding Alert](../Features/AddingAlert) For more information on how to add alerts to your model. #### Method Signature ```ruby alert_box(header: nil, body: nil, type: nil, icon_name: nil, partial: nil, partial_locals: {}, display_if: ->(_arg) { true }, html_attrs: {}) ``` --- ### `sort` Configure a sortable column for a model. Call it once for each column you want to make sortable. #### Parameters > **Note:** Default sort direction will be ascending. | Name | Type | Description | | ------------------- | --------- | ----------------------------------------------------------------------- | | `column` | `String` | The database column name. **Required**. | | `display_name` | `String` | The display name for the sort option. | | `default` | `Boolean` | Whether this is the default sort column. Default is `false`. | | `default_direction` | `String` | Default sort direction. Can be `'asc'` or `'desc'`. Default is `'asc'`. | | `datatype` | `String` | The SQL datatype for JSONB columns (e.g., `'TIMESTAMP'`, `'INTEGER'`). | | `sort_with` | `Symbol` | Custom scope method name on the model for custom sorting logic. | | `display_if` | `Proc` | Condition to display this sort option. | #### Examples 1. Sortable columns ```ruby sort column: 'id', display_name: 'ID' sort column: 'updated_at', display_name: 'Last Updated At' ``` 2. Sortable columns with default column and direction ```ruby sort column: 'id', display_name: 'ID', default: true, default_direction: 'desc' sort column: 'updated_at', display_name: 'Last Updated At' ``` 3. Sortable column on a JSONB attribute `verified_at` is a JSONB column, so specify the `datatype` of the underlying value: ```ruby sort column: 'verified_at', display_name: 'Verified At', datatype: 'TIMESTAMP' ``` 4. Sortable column with display_if condition ```ruby sort column: 'id', display_name: 'ID' sort column: 'created_at', display_name: 'Created At', display_if: ->(obj) { current_user.admin? } ``` 5. Sortable column with custom sort logic ```ruby sort column: 'request_due_date', display_name: 'Request Due Date', sort_with: :sort_by_due_date_nulls_first ``` **Note**: Define the corresponding scope on your model: ```ruby scope :sort_by_due_date_nulls_first, ->(direction) { order(Arel.sql("request_due_date #{direction} NULLS FIRST")) } ``` 6. Full example within `cm_index` ```ruby cm_index do sort column: 'name', display_name: 'Name' sort column: 'updated_at', display_name: 'Updated At' sort column: 'created_at', display_name: 'Created At', default: true, default_direction: 'desc' column :name column :status, field_type: :badge column :created_at, field_type: :date, format: '%d %b, %Y' end ``` #### Method Signature ```ruby sort(column:, display_name: nil, default: false, default_direction: nil, datatype: nil, sort_with: nil, display_if: nil) ``` --- ### `eager_load_associations` Configure eager loading for an action to avoid N+1 queries. | Name | Type | Description | | -------------- | ------- | ---------------------------------------- | | `associations` | `Array` | List of association names to eager load. | #### Example ```ruby eager_load_associations [:association_name] eager_load_associations [:constant] ``` #### Method Signature ```ruby eager_load_associations(associations = []) ``` > **Note:** cm-admin resolves these associations through [`ar_lazy_preload`](https://github.com/DmitryTsepelev/ar_lazy_preload) rather than ActiveRecord `includes`. Instead of loading every association eagerly at query time, the associations are registered on the relation and loaded for all returned records in a single query on first access — same N+1 protection, deferred until actually used. > > Host apps can go further and remove the need to declare `eager_load_associations` at all by enabling global auto-preload in an initializer: > > ```ruby > # config/initializers/ar_lazy_preload.rb > ArLazyPreload.config.auto_preload = true > ``` > > With `auto_preload`, every accessed association is preloaded for the whole relation on first access — at the cost of occasionally preloading associations only a single record needs. --- ### `field` Add a field to the show page to display model data. #### Parameters | Name | Type | Description | | ----------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `field_name` | `Symbol` | The name of the field (usually a model attribute or method). | | `field_type` | `Symbol` | The type of field. Can be `:association`, `:attachment`, `:badge`, `:boolean`, `:color_picker`, `:custom`, `:date`, `:datetime`, `:decimal`, `:drawer`, `:enum`, `:image`, `:integer`, `:link`, `:link_to_email`, `:link_to_phone`, `:money`, `:money_with_symbol`, `:rich_text`, `:string`, or `:text`. Default: `:string`. | | `label` | `String` | Custom label for the field. Defaults to titleized field name. | | `label_method` | `Symbol` | Method to call on the association for displaying the label. | | `format` | `String` | Format string for date/time fields (e.g., `'%d %b, %Y'`). | | `precision` | `Integer` | Decimal precision for numeric fields. Default: `2`. | | `width` | `Integer` | Width for image fields. Default: `50` for images. | | `height` | `Integer` | Height for image fields. Default: `50` for images. | | `helper_method` | `Symbol` | Custom helper method defined in `custom_helper.rb`. Receives `record` and `field_name` as arguments. | | `preview` | `Boolean` | Enable preview for the field. | | `link_text` | `String`, `Symbol` | Text for link, if Symbol is used, it will be method called from `custom_helper.rb`. | | `prefix` | `String` | Prefix to prepend to the field value. | | `suffix` | `String` | Suffix to append to the field value. | | `badge_class` | `Hash` | Badge classes for `:badge` fields. Values must be `cm-badge-*` classes. Example: `{ approved: 'cm-badge-green', draft: 'cm-badge-yellow' }`. Default: `{}`. | | `badge_helper_texts` | `Hash` | Helper texts for badge fields, shown as a tooltip icon next to the badge. Example: `{ approved: 'Reviewed by admin', rejected: 'Check rejection reason' }`. Default: `{}`. | | `display_if` | `Proc` | Lambda that takes the current object and returns boolean to conditionally display the field. Default: `->(_arg) { true }`. | | `association_name` | `Symbol` | Name of the association for association fields. | | `association_type` | `Symbol` | Type of association (`:belongs_to`, `:has_one`, `:polymorphic`). | | `col_size` | `Integer` | Column size in grid layout. | | `display_as_local_time` | `Boolean` | Whether to display datetime fields in the user's local time using the `local_time` helper. When `false`, the datetime is formatted server-side using `strftime`. Default: `true`. | | `timezone` | `String`, `Symbol`, `Proc` | Timezone to use when displaying `:datetime` fields. A `String` is used as a timezone name (e.g. `'Asia/Kolkata'`), a `Symbol` calls a helper method from `custom_helper.rb` with `(record, field_name)`, and a `Proc` receives the record and returns the timezone string. Overrides `display_as_local_time`. | #### Examples 1. Basic field ```ruby field :title field :created_at, field_type: :date, format: '%d %b, %Y' ``` 2. Field with tag styling ```ruby field :status, field_type: :badge, badge_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow', rejected: 'cm-badge-red' } ``` 3. Field with custom helper ```ruby field :amount, field_type: :custom, helper_method: :format_currency ``` 4. Image field ```ruby field :avatar, field_type: :image ``` 5. Email and phone fields (adds a copy-to-clipboard button) ```ruby field :email, field_type: :link_to_email field :phone, field_type: :link_to_phone ``` 6. Field with prefix and suffix ```ruby field :price, prefix: '$', suffix: 'USD' ``` 7. Conditional field display ```ruby field :admin_notes, display_if: ->(record) { record.admin_visible? } ``` 8. Field with Association a. Belongs to association (works for `:belongs_to` and `:has_one`) ```ruby field :title, field_type: :association, association_name: :parent, association_type: :belongs_to, label: 'Parent Task' ``` b. Polymorphic association ```ruby field [{ post: 'title', user: 'full_name' }], field_type: :association, association_name: :commentable, association_type: :polymorphic, label: 'Commented on' ``` #### Method Signature ```ruby field(field_name, attributes = {}) ``` --- ### `form_field` Add a field to the new or edit form for user input. #### Parameters | Name | Type | Description | | ----------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `field_name` | `Symbol` | The name of the field (usually a model attribute). | | `input_type` | `Symbol` | The input type. Can be `:integer`, `:decimal`, `:string`, `:single_select`, `:multi_select`, `:date`, `:date_time`, `:text`, `:phone`, `:switch`, `:custom_single_select`, `:checkbox_group`, `:single_file_upload`, `:multi_file_upload`, `:hidden`, `:rich_text`, `:check_box`, `:radio_button`, `:custom_string`, `:custom_date`, `:radio_button_group`, `:color_picker`, or `:email`. Default: `:string`. | | `label` | `String` | Custom label for the field. Defaults to titleized field name. | | `collection` | `Array` | Collection of options for select fields. | | `disabled` | `Proc` | Lambda that takes the current object and returns boolean to disable the field. Default: `->(_arg) { false }`. | | `helper_method` | `Symbol` | Custom helper method defined in `custom_helper.rb` to provide collection or custom rendering. | | `placeholder` | `String` | Placeholder text for the input. Defaults to "Enter \{field\}" or "Select \{field\}" based on input type. | | `display_if` | `Proc` | Lambda that takes the current object and returns boolean to conditionally display the field. Default: `->(_arg) { true }`. | | `html_attrs` | `Hash` | HTML attributes for the input element. Default: `{}`. | | `col_size` | `Integer` | Column size in grid layout. | | `options_url` | `String` | URL for fetching options asynchronously (for custom select fields). | | `helper_text` | `String` | Helper text displayed below the input field. | | `image_preview` | `Boolean` | Show image preview for file upload fields. Default: `false`. | | `can_create_new_entity` | `Boolean` | Allow creating a new entity from select fields via a drawer form. Default: `false`. | | `is_required` | `Boolean` | Mark field as required. If omitted, the field is automatically marked required when the model has a `presence` validator for it, or when the field is a non-optional `belongs_to` foreign key. Default: `false` when no automatic condition is met. | | `default_value` | `Proc`, `String`, `Symbol` | Default value for the field, can be a string, symbol or a proc. proc takes an argument and returns a value. symbol will be called as a method from `custom_helper.rb`. | | `timezone` | `String`, `Symbol`, `Proc` | Timezone to use when displaying and submitting `:date_time` fields. A `String` is used as a timezone name (e.g. `'Asia/Kolkata'`), a `Symbol` calls a helper method from `custom_helper.rb` with `(record, field_name)`, and a `Proc` receives the record and returns the timezone string. | | `label_link` | `Hash` | Renders a link next to the field label. Accepts `:url` (required), `:text` (default: `'Download'`), `:icon` (CSS icon class), and `:html_attrs` (extra HTML attributes for the link). The link opens in a new tab by default and downloads the file at `:url`. | #### Examples 1. Basic form fields ```ruby form_field :title, input_type: :string form_field :description, input_type: :text form_field :email, input_type: :email ``` 2. Select fields ```ruby form_field :status, input_type: :single_select, collection: ['draft', 'published', 'archived'] form_field :category_id, input_type: :single_select, helper_method: :category_collection form_field :tag_ids, input_type: :multi_select, helper_method: :tag_collection ``` 3. Date and datetime fields ```ruby form_field :published_at, input_type: :date form_field :event_time, input_type: :date_time form_field :event_time, input_type: :date_time, timezone: 'Asia/Kolkata' form_field :event_time, input_type: :date_time, timezone: ->(record) { record.user.timezone } form_field :event_time, input_type: :date_time, timezone: :record_timezone # calls record_timezone(record, field_name) in custom_helper.rb ``` 4. File upload fields ```ruby form_field :avatar, input_type: :single_file_upload form_field :documents, input_type: :multi_file_upload ``` 5. Field with conditional display ```ruby form_field :admin_notes, input_type: :text, display_if: ->(record) { current_user.admin? } ``` 6. Field with helper text and required ```ruby form_field :password, input_type: :string, helper_text: 'Minimum 8 characters', is_required: true ``` 7. Field with HTML attributes ```ruby form_field :bio, input_type: :text, html_attrs: { 'data-custom' => 'value' } ``` 8. Rich text and color picker ```ruby form_field :content, input_type: :rich_text form_field :theme_color, input_type: :color_picker ``` 9. Field with default value ```ruby form_field :name, input_type: :string, default_value: 'draft' form_field :name, input_type: :string, default_value: :user_name form_field :name, input_type: :string, default_value: ->(record) { record.name } ``` 10. Field with a label link ```ruby form_field :resume, input_type: :single_file_upload, label_link: { url: 'https://example.com/resume-template.docx', text: 'Download Template', icon: 'fa fa-download' } ``` #### Automatic required detection Catalyst automatically marks a form field as required when: - the model declares `validates :field_name, presence: true` - the field is a non-optional `belongs_to` foreign key (for example, `user_id` for `belongs_to :user`) You can still explicitly mark a field as required using `is_required: true`, even when the model has no presence validator. The same logic applies to nested form fields based on the associated model. #### Method Signature ```ruby form_field(field_name, input_type = :string, attributes = {}) ``` --- ## EditDsl ### `cm_edit` Create a form for the edit action. #### Parameters | Name | Type | Description | | ------------------ | ------------------ | ------------------------------------------------------ | | `display_name` | `String` | The display/button name of the page. | | `page_title` | `String \| Symbol` | The title of the page, symbol will use a model method. | | `page_description` | `String` | Description of the page. | | `partial` | `String` | Partial path of the page. | | `redirect_to` | `Proc, nil` | Lambda to redirect after update. | | `display_if` | `Proc` | Lambda to conditionally display the form. | #### Example — Editing page with redirect ```ruby cm_edit(display_name: 'Edit Post', page_title: "Edit Post", page_description: 'Enter all details to edit Post', redirect_to: ->(current_object) { "/pages/#{current_object.id}" }) do cm_section 'Details' do form_field :title, input_type: :string form_field :body, input_type: :rich_text end end ``` #### Method Signature ```ruby cm_edit(display_name: 'Edit', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, display_if: ->(_arg) { true }) ``` --- ## IndexDsl ### `cm_index` Create a table view for the index page with pagination. #### Parameters | Name | Type | Description | | ------------------ | -------- | ---------------------------------------------------------------------------------------- | | `display_name` | `String` | The display name of the page. | | `page_title` | `String` | The title of the page. If not provided, defaults to the formatted model name pluralized. | | `page_description` | `String` | The description of the page. | | `partial` | `String` | The partial path to render for the page. | | `view_type` | `Symbol` | The type of index view. Allowed values: `:table`, `:card`, `:kanban`. Default: `:table`. | --- #### Example — Basic index page ```ruby cm_index do page_title 'Post' column :title column :created_at, field_type: :date, format: '%d %b, %Y' column :updated_at, field_type: :date, format: '%d %b, %Y', header: 'Last Updated At' end ``` --- #### Display Types Supported | View Type | Usage | | --------- | ------------------------------------------------ | | `:table` | Default table layout with pagination. | | `:card` | Card-style layout (good for media-rich models). | | `:kanban` | Kanban-style board grouped by a status or field. | --- #### Notes - `cm_index` must wrap all columns and filters meant for the index view. - If no `page_title` is given, it automatically becomes the pluralized resource name. - To build a Kanban index, use `view_type: :kanban` together with a `kanban_view` declaration. --- #### Method Signature ```ruby cm_index(display_name: nil, page_title: nil, page_description: nil, partial: nil, view_type: :table) ``` --- ## Nested Form Field Feature ### Overview The Nested Form Field feature in cm-admin uses Stimulus.js for modern, performant dynamic form handling. It provides ability to add, remove, clone, and reorder fields dynamically in different views with smooth animations and enhanced user experience. ### Views - Card - Table - Table with grid #### Syntax ```ruby nested_form_field :media_items_by_position, default_view_type: :grid, is_positionable: lambda { |_obj| true }, display_type: :table_with_grid, allowed_grid_fields: %i[attachment name external_name], grid_column_count: 4, submit_text: 'Add Media Items' do form_field :name, label: 'Internal Name', placeholder: 'Enter internal name' form_field :external_name, placeholder: 'Enter external name' form_field :attachment, input_type: :single_file_upload, image_preview: true form_field :media_item_type, input_type: :single_select, helper_method: :media_item_type_list form_field :notes end ``` #### Parameters - **`grid_column_count:`** (optional) - It can be added when the display_type is table with grid. It will be used to manage the grid columns. Default value is 6. Allowed values are 4-6. - **`default_view_type:`** (optional) - It can be added to manage the default view type. Default value is table. Allowed values are [:table, :grid]. --- ## NewDsl ### `cm_new` Create a form for the new action. #### Parameters | Name | Type | Description | | --------------------- | ----------- | -------------------------------------------------- | | `display_name` | `String` | The display/button name of the page. | | `page_title` | `String` | The title of the page. | | `page_description` | `String` | Description of the page. | | `partial` | `String` | Partial path of the page. | | `redirect_to` | `Proc, nil` | Lambda to redirect after create. | | `allow_create_action` | `Proc` | Lambda to control whether the Add button is shown. | #### Example — Creating a new page with redirect ```ruby cm_new(display_name: 'Add Post', page_title: "Add Post", page_description: 'Enter all details to add Post', redirect_to: ->(current_object) { "/pages/#{current_object.id}" }) do cm_section 'Details' do form_field :title, input_type: :string form_field :body, input_type: :rich_text end end ``` #### Method Signature ```ruby cm_new(display_name: 'Add', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, allow_create_action: -> { true }) ``` --- --- ## ShowDsl ### `cm_show` Create a view for the show page. #### Parameters | Name | Type | Description | | ------------------ | ------------------ | ------------------------------------------------------------------------------------ | | `display_name` | `String` | The display name of the page. | | `page_title` | `String \| Symbol` | The title of the page. If a symbol is passed, it will be a method name on the model. | | `page_description` | `String` | The description of the page. | | `partial` | `String` | The partial path to render for the page. | #### Example — Showing page ```ruby cm_show page_title: :title do tab :profile, '' do cm_section 'Post Details' do field :title field :body, field_type: :rich_text field :is_featured field :status, field_type: :badge, badge_class: STATUS_TAG_COLOR end end end ``` #### Method Signature ```ruby cm_show(display_name: nil, page_title: nil, page_description: nil, partial: nil) ``` --- --- ## 3. Features Features available in cm-admin explained here. --- ## Adding Alert Feature ⚠️ ### Overview The Alert feature has been added to CmAdmin to provide a standardized method for displaying alerts and notifications to users. This documentation outlines how to use the Alert, including its configuration, types, and examples. ### Features - **Customizable Alerts:** Display alerts with headers, bodies, and specific types. - **Conditional Display:** Control when alerts should be shown based on dynamic conditions. - **Type Support:** Supports four predefined types of alerts: `info`, `success`, `danger`, and `warning`. ### Usage The Alert Banner is implemented using the `alert_box` helper. Below are the key elements and their usage: #### Syntax **1. Using Parameters:** ```ruby alert_box header:"Title", body:"Body1", type: :info, display_if:->(arg){arg.present?}, html_attrs:{} ``` **2. Using a Partial:** ```ruby alert_box partial:"/users/sessions/alert", display_if:->(arg){arg.present?}, html_attrs:{} ``` **3. Using a Partial with Locals:** ```ruby alert_box partial: "cm_admin/main/default_import_notes", partial_locals: { sample_file_path: '/sample.csv' } ``` #### Parameters - **`header:`** (optional) - The title text for the alert. - **`body:`** (optional) - A string to display as the body of the alert. - **`type:`** (optional) - The type of alert. Accepts one of the following symbols: `:info`, `:success`, `:danger`, `:warning`. - **`partial:`** (optional) - The path to a custom partial or HTML for the alert. - **`partial_locals:`** (optional) - A hash of local variables to pass to the partial. Default: `{}`. - **`display_if:`** (optional) - A lambda function that determines whether the alert should be shown. Should return a boolean. - **`html_attrs:`** (optional) - Additional HTML attributes to apply to the alert. Note: This has no effect on partials. ### Types of Alerts The following types are supported: - **`info`** - Blue alert for informational messages. - **`success`** - Green alert for successful actions or positive feedback. - **`danger`** - Red alert for errors or critical warnings. - **`warning`** - Yellow alert for cautionary messages. #### Examples ##### Example 1: Basic Info Alert ```ruby alert_box header:"Information", body:"This is an informational message.", type: :info, display_if:->(arg){arg.present?} ``` - **Screenshot:** ![image](/img/error.png) - **Description:** Displays an alert with a title and body text. ##### Example 2: Using Custom Body ```ruby alert_box header:"Information", body:"This is an informational message.
This is a break text.", type: :info, display_if:->(arg){arg.present?} ``` - **Screenshot:** ![image](https://github.com/user-attachments/assets/5614e104-4675-49e8-a2db-034a890fd581) - **Screenshot:** ![image](https://github.com/user-attachments/assets/41c22856-4647-4c47-ba48-d144833c2074) - **Description:** Displays a custom body for the alert, allowing for more customized content. ##### Example 3: Using Partial ```ruby alert_box partial:"/check/alert", display_if:->(arg){arg.present?} ``` - **Screenshot:** ![image](https://github.com/user-attachments/assets/5614e104-4675-49e8-a2db-034a890fd581) - **Screenshot:** ![image](https://github.com/user-attachments/assets/41c22856-4647-4c47-ba48-d144833c2074) - **Description:** Displays a custom partial for the alert, allowing for more customized HTML or content. #### Important Notes - **Nested Sections:** Alerts cannot be placed inside nested sections. If added within a nested section, the alert will appear on the wrapped `cm_show_section`. - **Unsupported Types:** Only the specified types (`info`, `success`, `danger`, `warning`) are supported. Any other type will default to a standard div. --- ## Advanced Filters ### Overview Advanced Filters let users build filter queries from **conditions** joined with **AND / OR**, nest them in **condition groups**, and pick an **operator** per condition (`is_not`, `greater_than`, `is_empty`, …). The standard filter bar only supports "equals / between". Advanced Filters are available automatically on every index page that defines `:single_select`, `:multi_select`, `:range`, `:date` or `:string` filters. No extra DSL or generator is needed. ### Usage Declare filters as usual: ```ruby cm_admin do cm_index do filter %i[name email], :search, placeholder: 'Search' filter :title, :string, placeholder: 'Search title' filter :status, :single_select, helper_method: :status_options filter :age, :range, placeholder: 'Age' filter :created_at, :date, placeholder: 'Created at' end end ``` On the index page, open **Filters → Add advanced filter**, then: 1. Pick a field, an operator, and a value. 2. Use **Add condition** to add more rows, and switch the connector between **And** and **Or**. 3. Use **Add condition group** to nest conditions, for example `status is active AND (age > 60 OR created_at between …)`. 4. Click **Apply**. :::note While Advanced Filters are active, the standard filter bar is hidden. Standard filters, including their `default_value`s, are **ignored**: only `search` and the advanced conditions are applied. Exports follow the same rule. ::: ### Operators | Filter type | Operators | Default | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | | `:single_select`, `:multi_select` | `is`, `is_not`, `is_empty`, `is_not_empty` | `is` | | `:date` | `between`, `not_between`, `is_empty`, `is_not_empty` | `between` | | `:range` | `between`, `not_between`, `is`, `is_not`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | `between` | | `:string` | `is`, `is_not`, `contains`, `does_not_contain`, `is_empty`, `is_not_empty` | `contains` | - `between` / `not_between` take a range value (`"18 to 30"`, `"2026-01-01 to 2026-01-31"`). - `is_empty` / `is_not_empty` take no value and check for `NULL` (for `:string`, `is_empty` also matches empty strings). - All other operators take a single value. - `:string` matches are case-insensitive and the value is matched literally — `%`, `_` and `\` are escaped, not treated as wildcards. `is_not` and `does_not_contain` also match `NULL` rows. Conditions are evaluated **left to right**: `A OR B AND C` means `(A OR B) AND C`. Use a condition group when you need a different precedence. ### Custom Filter Scopes (`filter_with`) Filters that use `filter_with` work in Advanced Filters too. The scope receives the selected operator as a second argument, `condition`, which is `nil` when the scope is called from the standard filter bar: ```ruby # :single_select / :multi_select scope :by_company_country, lambda { |value, condition = nil| left_joins(:company).ct_dropdown_filter_condition(column: :country, value:, condition:, table: 'companies') } # :range / :date. `value` is the raw string, e.g. "18 to 30" or "18" scope :by_company_size, lambda { |value, condition = nil| left_joins(:company).ct_range_filter_condition(column: :employee_count, value:, condition:, table: 'companies') } # :string scope :by_company_name, lambda { |value, condition = nil| left_joins(:company).ct_string_filter_condition(column: :name, value:, condition:, table: 'companies') } ``` `ct_dropdown_filter_condition`, `ct_range_filter_condition` and `ct_string_filter_condition` are available on every ActiveRecord model and implement all of the operators above. `table:` defaults to the model's own table. If you write the logic yourself, remember to handle `is_empty` / `is_not_empty`, which are called with a blank `value`. See [Custom Filter Method](./CustomFilterMethod.md). ### URL Format Filters, both standard and advanced, are stored in the `filters` query param as zlib-compressed, base64url-encoded JSON. Once decoded, an advanced filter looks like this: ```json { "search": "john", "advanced": [ { "filter_type": "single_select", "filter_name": "status", "condition": "is", "value": "active", "operator": "and" }, { "filter_type": "group", "operator": "and", "value": [ { "filter_type": "range", "filter_name": "age", "condition": "greater_than", "value": "60", "operator": "and" }, { "filter_type": "date", "filter_name": "created_at", "condition": "between", "value": "2026-01-01 to 2026-01-31", "operator": "or" } ] } ] } ``` `operator` joins an item to the items before it. The first item's `operator` is ignored. Use the helpers below whenever you read or build this param in Ruby. The legacy nested `filters[...]` hash is still accepted: ```ruby filters = CmAdmin::Utils.decode_filter_params(params[:filters]) || {} cm_admin.cm_index_user_path(filters: CmAdmin::Utils.encode_filter_params(filters)) ``` To save a set of advanced filters and reuse it later, see [Saved Views](./SavedViews.md). --- ## AssociatedSort ### 📚 Sorting by Delegated Attributes in CM Admin CM Admin supports sorting on delegated or associated model attributes by adding them with the `sort` DSL method. This is particularly useful when you want to sort based on related models, such as `organisation.name` or `cm_role.name`. #### ✅ Prerequisites Ensure that: - The associated attributes are properly **delegated** in the model. - Your database joins can support sorting on the associated table columns. #### 🛠 Example Setup Given a model like: ```ruby # app/models/user.rb class User < ApplicationRecord belongs_to :organisation belongs_to :cm_role delegate :name, to: :organisation, prefix: true, allow_nil: true delegate :name, to: :cm_role, prefix: true, allow_nil: true end ``` In your **CM Admin File**: ```ruby # app/models/cm_admin/user.rb sort column: 'created_at', display_name: 'Created At', default: true, default_direction: 'desc' sort column: 'updated_at', display_name: 'Updated At' sort column: 'first_name', display_name: 'Name' sort column: 'organisation_name', display_name: 'Organisation' sort column: 'cm_role_name', display_name: 'Role' ``` #### 🧠 Behind the Scenes To support sorting via associations: - CM Admin uses Arel or ActiveRecord `joins` and `order` clauses. - The `column` name (e.g., `organisation_name`) should match a **delegated** method or virtual attribute on the model. #### ⚠️ Notes - If you encounter SQL errors when sorting on these columns, you may need to **explicitly define the join logic** in your CM Admin controller or scope. - If `organisation_name` or `cm_role_name` is not indexed or heavily queried, consider adding proper database indexes. --- ## Auditing (Custom-Action Audit Metadata) ### Overview cm-admin extends PaperTrail to record **two first-class audit dimensions** on every version record: - `action_type` — the category of operation (`default`, `custom`, `bulk_action`, or `tab`) - `action_name` — the specific action (`create`, `update`, `destroy`, or the name of a custom action like `approve`) These are stored as queryable columns on the PaperTrail `versions` table, so you can filter and report on audit history by what kind of action triggered each change — not just _who_ did it (`whodunnit`) and _what_ changed (`object` / `object_changes`). Best of all, **developers keep writing bare `has_paper_trail`** — the engine auto-injects the metadata configuration. No per-model setup is required. --- ### Quick Start #### 1. Add the columns to your `versions` table Run the generator and migrate: ```bash rails g cm_admin:add_audit_metadata_to_versions rails db:migrate ``` The generator creates a migration that adds `action_type` and `action_name` (both `string`) to `versions`, plus indexes on each for fast filtering: ```ruby class AddAuditMetadataToVersions < ActiveRecord::Migration[8.1] def change add_column :versions, :action_type, :string add_index :versions, :action_type add_column :versions, :action_name, :string add_index :versions, :action_name end end ``` If only one of the columns is missing, the generator only adds the missing one. #### 2. That's it Any model that already declares `has_paper_trail` will now automatically record `action_type` and `action_name` on each version — no changes to the model are needed. --- ### How It Works 1. **`CmCurrent`** (cm-admin's `ActiveSupport::CurrentAttributes` store) gains two attributes: `action_type` and `action_name`. 2. **`CmAdmin::ResourceController`** sets them at the start of each mutating request: - `cm_create` → `action_type: 'default'`, `action_name: 'create'` - `cm_update` → `action_type: 'default'`, `action_name: 'update'` - `cm_destroy` → `action_type: 'default'`, `action_name: 'destroy'` - `cm_custom_action` → `action_type: 'custom'`, `action_name: <the custom action's name>` - `cm_bulk_action` → `action_type: 'bulk_action'`, `action_name: <the bulk action's name>` They are reset to `nil` at the end of the request. 3. **`has_paper_trail` is auto-wrapped** by the engine. When called on a model, it injects: ```ruby meta: { action_type: ->(_record) { CmCurrent.action_type }, action_name: ->(_record) { CmCurrent.action_name } } ``` but **only if** the `versions` table actually has those columns (so apps that haven't run the generator are not broken). --- ### Querying Audit History Because `action_type` and `action_name` are real columns, you can filter and aggregate efficiently: ```ruby # All versions created by custom actions record.versions.where(action_type: 'custom') # All versions of a specific custom action across all records PaperTrail::Version.where(action_type: 'custom', action_name: 'approve') # Every update made through the standard edit form record.versions.where(action_type: 'default', action_name: 'update') # All bulk action mutations record.versions.where(action_type: 'bulk_action') # Audit history for a single custom action record.versions.where(action_name: 'approve').order(created_at: :desc) ``` --- ### History Page Display The cm-admin history tab automatically renders the audit metadata. For standard CRUD operations it shows the usual text (e.g. "Jane updated the Demo Form 2 minutes ago"). For custom and bulk actions it appends **via the \{display name\} \{action type\}**: > Jane updated the Demo Form via the Approve Custom Action 2 minutes ago > John updated the Demo Form via the Approve Bulk Action 5 minutes ago The display name is resolved from the cm-admin action's `display_name` (falling back to the action name humanized), so it matches what users see on buttons and modals — no extra configuration needed. The action type label distinguishes whether the change was made through a custom action or a bulk action. --- ### Values Reference | `action_type` | `action_name` | When | | ------------- | --------------- | ---------------------------------------------------------------------------- | | `default` | `create` | Record created via cm-admin new/create | | `default` | `update` | Record updated via cm-admin edit/update | | `default` | `destroy` | Record destroyed via cm-admin destroy | | `custom` | `` | A cm-admin `custom_action` mutated the record | | `bulk_action` | `` | A cm-admin `bulk_action` mutated the record | | `nil` | `nil` | Change made outside a cm-admin request (e.g. background job, console, seeds) | --- ### Overriding / Extending If a model supplies its own `meta:` for `action_type` or `action_name`, cm-admin respects it and does **not** clobber it: ```ruby class Order < ApplicationRecord has_paper_trail meta: { action_type: ->(_) { 'order_specific' } } end # Order's `action_type` will be 'order_specific', not 'default'/'custom'/'bulk_action'. # `action_name` is still auto-injected from CmCurrent. ``` To opt out entirely for a model, pass both keys: ```ruby has_paper_trail meta: { action_type: ->(_) { nil }, action_name: ->(_) { nil } } ``` --- ### Requirements - The `paper_trail` gem must be installed and `has_paper_trail` declared on the model. - The `versions` table must have `action_type` and `action_name` columns (added by the generator above). - `Current.user` must be set in the request lifecycle (already required by cm-admin for `whodunnit` and `Trackable`). --- ### Notes - **Historical versions:** Existing `versions` rows will have `NULL` for `action_type`/`action_name`. Only versions created after the migration is run get populated. - **Background jobs:** `CmCurrent` is request-scoped, so changes made in background jobs record `NULL` for both fields — which is correct, since no HTTP action triggered them. - **The `has_paper_trail` wrapper is global:** it affects every `ActiveRecord` model in the host app, not just cm-admin DSL models. This is intentional and harmless — models without the `versions` columns simply skip meta injection, and developer-supplied `meta:` is always respected. --- ## Bulk Action Documentation coming soon. --- ## Catalyst AI (CT Bot) cm-admin ships with Catalyst AI, an opt-in AI assistant that lives inside the admin panel. Users with an enabled role get a floating chat widget on every admin page and can ask questions in natural language — the assistant answers using read-only [MCP tools](./McpServer.md) against your admin-registered models, streams the reply live, and can ask clarifying questions back when a request is ambiguous. --- ### What you get - **Floating chat widget** on every admin page — conversation history, unread badges, paginated messages, file attachments, and a Lexxy rich-text composer - **Streaming replies** over ActionCable — the answer renders chunk by chunk - **Clarifying questions** — when the request is ambiguous, the assistant asks via an `ask_user_question` tool and the user answers on an options card - **Per-role opt-in** — the `is_catalyst_ai_enabled` flag on `CmRole` - **Cost tracking** — every LLM request is recorded as a `CtCostTracking` (tokens per type, total cost, and the raw request payload for audit), with totals rolled up onto each message and conversation - **Permission-scoped answers** — tool calls run through the same Pundit policy scopes as the web UI, evaluated for the asking user --- ### Setup #### 1. Generate and run the migrations ```bash rails generate cm_admin:add_bot_conversations rails generate cm_admin:add_bot_transcriptions rails db:migrate ``` `add_bot_conversations` creates `ct_bot_conversations`, `ct_bot_messages`, `ct_bot_artifacts`, `ct_cost_trackings`, and adds `is_catalyst_ai_enabled` to `cm_roles`. `add_bot_transcriptions` (added in v8.4.1) creates `ct_bot_transcriptions` for voice input. See the generator reference for [Add Bot Conversations](../Generators.md#22-add-bot-conversations-generator) and [Add Bot Transcriptions](../Generators.md#24-add-bot-transcriptions-generator). #### 2. Register the models ```ruby # config/initializers/zcm_admin.rb config.included_models = [..., CtBotConversation, CtBotMessage, CtBotTranscription, CtCostTracking] ``` #### 3. Add your OpenRouter API key ```bash rails credentials:edit ``` ```yaml catalyst_ai: openrouter_api_key: sk-or-... ``` #### 4. Configure ActionCable Replies stream over `CmAdmin::CtBotChannel`. Make sure `config/cable.yml` exists — Redis is recommended outside development: ```yaml production: adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> ``` #### 5. Enable the assistant on roles Edit each `CmRole` in the admin panel and switch on **Catalyst AI**. The widget renders only for users whose role has the flag — and the chat endpoints return `404` without it. #### 6. Expose models to the assistant The assistant can only query models that declare `mcp_actions` (same mechanism as the external [MCP Server](./McpServer.md)): ```ruby class Article < ApplicationRecord cm_admin do mcp_actions end end ``` Models without `mcp_actions` are invisible to the assistant. #### 7. (Optional) Give the assistant domain context `config.mcp_system_prompt` doubles as the bot's system prompt — set it to explain your data model and business semantics: ```ruby # config/initializers/zcm_admin.rb CmAdmin.configure do |config| config.mcp_system_prompt = <<~PROMPT You are the Acme Platform admin assistant. - `assignee` is the current owner of a work item. - Prefer aggregate tools for counts and totals. PROMPT end ``` --- ### Platform settings Four admin-managed settings under the **Catalyst AI** category configure runtime behavior (create them via `CmPlatformSetting.add_cm_default_settings` or the Platform Settings page): | Setting | Slug | Purpose | Default | | -------------------------- | ---------------------------- | ------------------------------------------------------------------ | ------------------------------- | | CT Bot Greeting | `ct-bot-greeting` | Message shown at the start of every new chat | `Hi! How can I help you today?` | | CT Bot Model | `ct-bot-model` | OpenRouter model ID (e.g. `openai/gpt-5.6`) | `openai/gpt-5.6-sol` | | CT Bot Queue | `ct-bot-queue` | Sidekiq queue used for `CtBotReplyJob` and `CtBotTranscriptionJob` | `default` | | CT Bot Transcription Model | `ct-bot-transcription-model` | OpenRouter model ID used to transcribe voice input | `openai/gpt-transcribe` | --- ### How it works 1. The widget renders in `layouts/cm_admin` when `Current.user.cm_role.catalyst_ai_enabled?`. It opens on the user's most recent conversation (or a fresh one) inside a lazy turbo frame. 2. Sending a message creates a `CtBotMessage` (`role: :user`), which triggers `request_reply`: open artifacts are closed, a pending assistant message is created, and `CtBotReplyJob` is enqueued to answer it. 3. The job builds a `CtBotAgent` — a `ruby_llm` agent connected in-process to `CmAdmin::Mcp::Server` — loads the conversation history, and calls `ask`. `Current.user` is set to the conversation owner, so every MCP tool call is scoped by `CmAdmin::PolicyIndexScope` for that user. 4. Streamed chunks are broadcast over the signed `CmAdmin::CtBotChannel` stream (throttled to ~4/second); the pending message partial re-renders live until the reply finishes. 5. If the agent calls `ask_user_question`, the pending tool call is saved as a `CtBotArtifact` and rendered as a radio/checkbox card. Submitting an answer creates a user message, which triggers a fresh reply. 6. `ruby_llm` usage entries are persisted as `CtCostTracking` records with the request payload attached. Each tracking refreshes the message's `total_cost` (sum of its trackings), which in turn refreshes the conversation's `total_cost` — visible per message and in the Cost Trackings index (filterable by provider and model). 7. On failure the reply is marked `failed` with a friendly error message and the exception is reported via Airbrake. 8. Voice input uploads the recording as a `CtBotTranscription` (audio attached, linked to the open conversation if any) and enqueues `CtBotTranscriptionJob`. The job stores the transcribed text, status and cost (as `CtCostTracking`), then broadcasts the result over the signed `CmAdmin::CtBotTranscriptionChannel` stream. When a message is sent in that conversation, the transcription is linked to the `CtBotMessage`. --- ### Security notes - All chat endpoints require a Catalyst-AI-enabled role and scope every query to `Current.user` — users can only read their own conversations. - Channel subscriptions use signed stream names; a stream for another user's conversation can't be forged. - Attachment previews are served inline only for non-SVG images; everything else downloads as an attachment. - The bot's data access is read-only and goes through the same Pundit policy scopes as the web UI — it cannot see records the asking user couldn't. ### Admin views - **Bot Conversations** — index + show (with a Messages tab) for auditing chats, including a rolled-up Total Cost column - **Bot Messages** — index/show per message with Total Cost, including a Cost Trackings tab - **Bot Transcriptions** — index/show per voice recording with its audio, text, status and Total Cost, including a Cost Trackings tab - **Cost Trackings** — global index with provider/model filters Permission rows for `CtBotConversation`/`CtBotMessage` appear on a role only while Catalyst AI is enabled; disabling the flag removes them. --- ## CmAdmin Configuration All cm-admin global settings live in the initializer file (`config/initializers/cm_admin.rb` by default) and are exposed through the `CmAdmin.configure` block. The block is applied once every app initializer has run, so the file can have any name and the models it references can rely on your other initializers (Devise, constants, etc.). Changes to the block need a server restart, but model references in it are refreshed on every code reload in development. ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| config.layout = 'admin' config.included_models = [User, Post] config.project_name = 'My App' # ...see sections below for every option end ``` --- ### Quick Reference | Option | Type | Default | Description | | ------------------------- | ----------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `layout` | `String` | `'admin'` | Name of the Rails layout used to render cm-admin pages. | | `included_models` | `Array` | `[]` | Models registered with cm-admin (the ones that appear in the sidebar and routes). | | `cm_admin_models` | `Array` | `[]` | Resolved `CmAdmin::Model` wrappers. Populated automatically — do not set manually. | | `project_name` | `String` | `''` | Display name shown in the browser title and header. | | `sidebar` | `Array` | `[]` | Custom sidebar navigation entries. When empty, the sidebar is auto-generated from `included_models`. | | `auth_method` | `Symbol` | `:otp` | Authentication strategy. Valid values: `:otp`, `:password`, `:custom`. | | `use_catalyst_otp` | `Boolean` | `false` | Opt-in flag to register the gem's Devise strategy and model for OTP auth. | | `user_class` | `String\|Class` | `'User'` | The host app user model. Used by auth lookup, mentions, support tickets, and history. Accepts a class or the model name as a string. | | `user_lookup` | `Proc` | email/phone lookup | Lambda that resolves a user from an identifier (email or phone). Override for custom lookup logic. | | `otp_delivery_channel` | `Symbol` | `:email` | How OTPs are delivered. Valid values: `:email`, `:phone`, `:phone_or_email`. | | `otp_delivery_provider` | `Symbol\|Object` | `nil` | Phone delivery provider. `:gupshup` or any object responding to `#deliver`. Required when channel is `:phone` or `:phone_or_email`. | | `display_role_on_sidebar` | `Boolean` | `true` | Whether to show the current user's role on the sidebar. | | `display_date_format` | `String` | `'M d, Y'` | Flatpickr/JS date format used when rendering date fields. | | `display_datetime_format` | `String` | `'M d, Y, H:i K'` | Flatpickr/JS datetime format used when rendering datetime fields. | | `global_search_models` | `Array` | `[]` | Models searchable from the global search bar. | | `enable_tracking` | `Boolean` | `false` | Enables Google Tag Manager / tracking snippets in the layout. | | `enable_email_logging` | `Boolean` | `false` | Logs every `CmAdmin.send_email` delivery to the `cm_email_logs` table. | | `email_template_settings` | `Hash` | `{ show_copyright: true, footer_content: nil }` | Default email template options (copyright footer, custom footer content). | | `mcp_enabled` | `Boolean` | `false` | Mounts the built-in MCP server endpoint at `/mcp`. | | `mcp_system_prompt` | `String\|nil` | `nil` | Custom system prompt sent to MCP AI clients during the `initialize` handshake. | | `layout_css_files` | `Array` | `[]` | Extra host-app SCSS files compiled into the cm-admin CSS bundle. | | `stylesheet_import_paths` | `Array` | `[]` | Extra directories the Sass compiler searches when resolving `@import`/`@use` in bundled SCSS. Useful for exposing stylesheet directories from Ruby gems. | | `count_threshold` | `Integer\|nil` | `50_000` | Minimum planner row estimate before index pages skip the exact `COUNT` and show an estimated total with a `+` suffix. `nil` disables estimation. | --- ### Layout & Branding #### `layout` Name of the Rails layout used to render cm-admin pages. Defaults to `'admin'`. ```ruby config.layout = 'admin' ``` #### `project_name` Shown in the browser title and the cm-admin header. Commonly wired to your Rails project settings: ```ruby config.project_name = Rails.configuration.x.project_settings.name ``` #### `sidebar` When provided, replaces the auto-generated sidebar with custom navigation entries. When left empty (default), cm-admin builds the sidebar from `included_models`, filtered by the current user's policies. ```ruby config.sidebar = [ { label: 'Dashboard', path: '/dashboard', icon: 'fa-solid fa-gauge' }, { label: 'Users', path: '/cm_admin/users', icon: 'fa-solid fa-users' } ] ``` #### `display_role_on_sidebar` When `true` (default), the current user's role name is displayed on the sidebar. Set to `false` to hide it. ```ruby config.display_role_on_sidebar = false ``` --- ### Models #### `included_models` The list of ActiveRecord models that cm-admin should register. Each model must have a corresponding `CmAdmin::` concern with a `cm_admin` DSL block. Only models listed here appear in the sidebar, routes, and MCP tool discovery. ```ruby config.included_models = [User, Post, CmRole, ApiToken] ``` > Generator-based features (scheduler, support tickets, etc.) will prompt you to add their models here. #### `cm_admin_models` This is a runtime-populated collection of `CmAdmin::Model` wrappers built from `included_models`. **Do not set this manually** — it is managed by cm-admin during boot. #### `global_search_models` Models that should be searchable from the global search bar in the header. The first three are surfaced as quick links in the search dropdown. ```ruby config.global_search_models = [User, Post] ``` --- ### Authentication #### `auth_method` Controls which login flow cm-admin renders. Valid values are `:otp` (default), `:password`, and `:custom`. Setting an invalid value raises `ArgumentError`. ```ruby config.auth_method = :password ``` - `:otp` — identifier-based OTP login. The user enters their email or phone and receives a one-time code. - `:password` — identifier + password login. - `:custom` — host app provides its own authentication flow. The Devise strategy signs the user in directly via `super(user)`. The chosen method also toggles password-related UI (e.g. "forgot password" links are only shown for `:password`). See [OtpAndAuth](./OtpAndAuth) for the full setup guide including migration from host-app controllers to the gem. #### `use_catalyst_otp` Opt-in flag that registers the gem's Devise strategy (`CmAdminAuthenticatable`) and model module. When `false` (default), the gem does not interfere with the host app's Devise setup. When `true`, the gem registers a Warden strategy and Devise module via `ActiveSupport::Reloader.to_prepare` so it runs after app initializers but before eager load. ```ruby config.use_catalyst_otp = true ``` #### `user_class` The host app's user model. Defaults to `'User'` and is lazily constantized, so it can be set before the model is loaded or after a Rails code reload. Pass either the class or its name as a string. ```ruby config.user_class = 'AdminUser' # or config.user_class = AdminUser ``` This value is used by the default `user_lookup`, the support-ticket @mentions model, the history page (`whodunnit` resolution), and any other cm-admin feature that needs to talk to the user table. Changing it avoids hardcoded `::User` references. #### `user_lookup` A lambda that resolves a user from an identifier string (email or phone). The default lookup tries `config.user_class.find_by(email:)` first, then falls back to a `LIKE` match on `phone_number` using the trailing digits. ```ruby config.user_lookup = lambda { |identifier| AdminUser.find_by(email: identifier) || AdminUser.find_by(phone_number: identifier) } ``` The lambda is called by the Devise strategy and the `OtpSessions` concern. Override it to customize lookup logic (e.g. normalized phone formats). #### `otp_delivery_channel` Controls how OTP codes are delivered. Valid values: `:email` (default), `:phone`, `:phone_or_email`. Setting an invalid value raises `ArgumentError`. ```ruby config.otp_delivery_channel = :phone_or_email ``` - `:email` — sends OTP via email only (using `CmAdmin::DeliveryProviders::Email`). - `:phone` — sends OTP via the configured `otp_delivery_provider` only. - `:phone_or_email` — the login screen shows a toggle between phone and email login. A phone field (with country-code selector) is shown by default, with a "Use email instead" link to switch to an email field (and a "Use phone instead" link to switch back). OTP delivery follows the chosen identifier: phone numbers are sent via the phone provider only, emails via email only. #### `otp_delivery_provider` The provider used for phone-based OTP delivery. Accepts a Symbol (resolved to a class under `CmAdmin::DeliveryProviders`) or any object responding to `#deliver(user:, otp:, template:)`. ```ruby # Using the built-in Gupshup provider config.otp_delivery_provider = :gupshup ``` The gem ships with `CmAdmin::DeliveryProviders::Gupshup` for WhatsApp OTP delivery. Gupshup credentials are read from `Rails.application.credentials[:gupshup]` and should include `:api_key`, `:source_number`, and `:templates` (a hash mapping template names to Gupshup template IDs). To use a custom provider, pass any object that responds to `#deliver`: ```ruby class MySmsProvider def deliver(user:, otp:, template:) # send SMS via your provider end end config.otp_delivery_provider = MySmsProvider.new ``` --- ### Date & Time Formatting #### `display_date_format` and `display_datetime_format` These are Flatpickr formatting strings passed to the JS date/datetime pickers used across index and form views. ```ruby config.display_date_format = 'M d, Y' # e.g. "Aug 6, 2026" config.display_datetime_format = 'M d, Y, H:i K' # e.g. "Aug 6, 2026, 14:30 PM" ``` > These only affect the picker UI. Use the `format:` option on individual `column`/`field` declarations to override how stored values are rendered. --- ### Email #### `enable_email_logging` When `true`, every email sent via `CmAdmin.send_email` is recorded in the `cm_email_logs` table with delivery status, recipients, failure reasons, and record association. Disabled by default. ```ruby config.enable_email_logging = true ``` To set up the required table: ```bash rails g cm_admin:add_email_logs rails db:migrate ``` See [CmAdminEmail](./CmAdminEmail) for the full email sending API. #### `email_template_settings` Default options applied to every email template unless overridden per-call: ```ruby config.email_template_settings = { show_copyright: true, # render the © footer footer_content: 'Acme Inc.' # custom HTML/text shown above the copyright } ``` Both keys can be overridden on individual `CmAdmin.send_email` calls via the `email_template_settings:` argument. --- ### Tracking #### `enable_tracking` When `true`, cm-admin injects Google Tag Manager snippets into the layout head and body. Use this only in environments where GTM is configured. ```ruby config.enable_tracking = true ``` --- ### MCP Server cm-admin ships with a built-in [Model Context Protocol](https://modelcontextprotocol.io) server that exposes your admin-registered models to AI clients. See [McpServer](./McpServer) for the full setup guide. #### `mcp_enabled` When `true`, the MCP Rack endpoint is mounted at `/mcp` and models with `mcp_actions` in their DSL block become available as `list`/`show` tools. Defaults to `false`. ```ruby config.mcp_enabled = true ``` #### `mcp_system_prompt` A custom system prompt sent to the AI client during the MCP `initialize` handshake. This is the right place to inject domain-specific knowledge, business semantics, and known limitations the AI should respect. ```ruby config.mcp_system_prompt = <<~PROMPT You are reading records from the Acme Platform via MCP tools. Data model notes: - `assignee` is the current owner of a work item, not the original creator. - `internal_feedback_rating` is unused; QA ratings live inside each work item's history and cannot be bulk-aggregated through MCP tools. Best practices: - Use aggregate tools for counts/sums/averages instead of listing all rows. - Use `fetch_options` to resolve select filter values before filtering. PROMPT ``` When left `nil` (default), the server falls back to a generic one-line instruction. Keep the prompt focused on semantic guidance — avoid pasting the full schema, which the AI can already discover through `list_models` and tool schemas. --- ### CSS Bundling #### `layout_css_files` Extra SCSS files from the host app (`app/assets/stylesheets/`) that should be compiled into the cm-admin CSS bundle. Each entry is a filename relative to `app/assets/stylesheets/`. ```ruby config.layout_css_files = ['application.scss', 'theme_overrides.scss'] ``` This is used by the cm-admin CSS bundler (`CmAdminCssBundler`) when building the Tailwind/Sass output. Each file is compiled to `app/assets/builds/.css`. #### `stylesheet_import_paths` Extra directories the Sass compiler should search when resolving `@import`/`@use` directives in the bundled stylesheets. This is commonly used to expose stylesheet directories from Ruby gems so SCSS can find their partials. ```ruby config.stylesheet_import_paths = [ "#{Gem.loaded_specs['bootstrap'].full_gem_path}/assets/stylesheets", "#{Gem.loaded_specs['select2-rails'].full_gem_path}/vendor/assets/stylesheets", "#{Gem.loaded_specs['intl-tel-input-rails'].full_gem_path}/app/assets/stylesheets" ] ``` These paths are passed to the Sass compiler as `--load-path=` flags and are combined with the default `layout_css_files` build. --- ### Pagination #### `count_threshold` Minimum row estimate required before index pages substitute an `EXPLAIN`-based planner estimate for the exact `COUNT(*)` query. On large tables this avoids a full count scan on every page load; estimated totals render with a `+` suffix (e.g. "50,000+ records found"). Estimates come from `fast_count`, which works on PostgreSQL; on other databases index pages always use the exact count. Defaults to `50_000`; set to `nil` to always use exact counts. ```ruby config.count_threshold = 100_000 # or disable estimation entirely config.count_threshold = nil ``` #### `fast_count` cm-admin adds `fast_count` to every ActiveRecord relation and model. It returns the PostgreSQL planner's row estimate from `EXPLAIN` without running the query, or `nil` when there is no estimate (for example on other databases). It ignores `count_threshold`, so apps can use it anywhere an approximate total is enough. ```ruby Trip.fast_count # => 306_655 (estimate for the whole table) Trip.where(status: "active").fast_count # => 41_200 (estimate for the filtered rows) ``` --- ### Deployment To suppress Hatchbox deployment Slack notifications, set `DEPLOYMENT_ALERTS` to `false` in your Hatchbox environment variables — see [HatchboxScripts](../4.%20Deployment/HatchboxScripts.md#skipping-notifications). --- ## Sending Email from CM Admin 📧 ### Overview The CM Admin email feature allows you to send emails directly from CM Admin. This documentation outlines how to use CM Admin emails, including configuration and examples. ### Usage To use CM Admin email, use the `CmAdmin.send_email` method. #### Attributes - **`from`** (optional) - Specify from whom the email should be delivered. Takes a string value. - If not specified, uses the default from email from project settings. - **`to`** (required) - Specify to whom the email should be delivered. Takes an array or single email address. - **`cc`** (optional) - Specify carbon copy recipients. Takes an array or single email address. - **`bcc`** (optional) - Specify blind carbon copy recipients. Takes an array or single email address. - **`reply_to`** (optional) - Specify reply-to email address. Takes a string value. - **`subject`** (required) - Subject of the email. Takes a string value. - **`template`** (optional) - Template of the email. Takes a symbol value. - Options: `:blank`, `:standard` (default) - **`partial_file_path`** (optional) - Partial file path of the email template. Takes a string value. - **`partial_locals`** (optional) - Partial locals for the email template. Takes a hash value. - **`body`** (optional) - Body of the email. Takes an HTML string or plain text string. - **`button_text`** and **`button_link`** (optional) - If the email template requires a button with a link, use these attributes. - **`attachments`** (optional) - If the email requires attachments, use this attribute. - **`record`** (optional) - Associate the email log with a specific record. Takes an ActiveRecord object. - Automatically sets `module_name`, `record_type`, `record_id`, and `record_url` on the email log. - **`notify_on_failure`** (optional, default: `true`) - Whether to send an admin failure notification email if delivery fails. Takes a boolean value. - Set to `false` for system-internal emails (e.g. failure notifications themselves) to prevent recursive loops. #### Examples Here are some examples of how to use the `CmAdmin.send_email` method to send emails: ##### Basic Email with Partial Template ```ruby CmAdmin.send_email( to: ['anbu@commutatus.com'], subject: "[DMSv2] New Prospect Created!", partial_file_path: '/cm_admin/mailers/prospect_admin_notification', partial_locals: { user: User.last } ) ``` ##### With CC and BCC Recipients ```ruby CmAdmin.send_email( to: 'user@example.com', cc: 'manager@example.com', bcc: ['admin@example.com', 'audit@example.com'], reply_to: 'noreply@example.com', subject: "Project Update", body: '

Here is the latest project update.

' ) ``` ##### With Custom From Email ```ruby CmAdmin.send_email( from: 'info@commutatus.com', to: ['anbu@commutatus.com'], subject: "[DMSv2] New Prospect Created!", partial_file_path: '/cm_admin/mailers/prospect_admin_notification', partial_locals: { user: User.last } ) ``` ##### With Link Button ```ruby CmAdmin.send_email( to: ['info@commutatus.com'], subject: "New User Created!", partial_file_path: '/cm_admin/mailers/user_admin_notification', partial_locals: { user: User.last }, button_text: 'View user', button_link: 'http://domain.com/user/1' ) ``` ##### Simple HTML Content ```ruby CmAdmin.send_email( to: ['anbu@commutatus.com'], subject: "[DMSv2] New Prospect Created!", body: '

Simple HTML content to be added here.

' ) ``` > **Note:** `button_text` and `button_link` are optional parameters. ##### Email with Blank Template ```ruby CmAdmin.send_email( to: ['anbu@commutatus.com'], subject: "[DMSv2] New Prospect Created!", template: :blank, body: '

Simple HTML content to be added here.

' ) ``` ##### Email with Attachments ```ruby CmAdmin.send_email( to: ['anbu@commutatus.com'], subject: "[DMSv2] New Prospect Created!", body: '

Simple HTML content to be added here.

', attachments: [ { filename: 'sample.pdf', file_content: '...' }, { filename: 'sample.pdf', url_path: '...' } ] ) ``` ### Configuration #### Email Logging By default, email logging is disabled. To enable it, add the following to your initializer: ```ruby CmAdmin.config.enable_email_logging = true ``` When enabled, all emails sent via `CmAdmin.send_email` will be logged in the `cm_email_logs` table with delivery status, failure reasons, and recipient tracking. To set up the required table, run the generator: ```bash rails g cm_admin:add_email_logs rails db:migrate ``` ### Important Notes #### Attachments - Attachments should be an array of hashes - Each hash should have exactly two keys: 1. `filename` - Name of the file 2. `file_content` OR `url_path` - Either base64 encoded content or URL path #### File Content Encoding The `file_content` should be a base64 encoded string of the file content/blob. You can use: 1. **From file path:** `Base64.encode64(File.open(file_path).read)` 2. **From URL:** `Base64.encode64(URI.open(file_url).read)` --- ## Webhook Logging in CM Admin ### Overview CM Admin provides a base `WebhooksController` that handles incoming webhook requests with async processing and a debug UI. Vendor-specific webhook controllers inherit from this base class and override the methods they need. ### Configuration #### Enabling Webhook Logging By default, webhook logging is disabled. To enable it, add the following to your initializer: ```ruby CmAdmin.config.enable_webhook_processing = true ``` When enabled, webhook events are stored as `CmWebhookEvent` records and processed asynchronously via `ProcessWebhookEventJob`. The **Webhook Events** admin panel shows all logged events with their service, event type, payload, status, and error information. When disabled, webhooks are still processed — but synchronously via an overridden `process_webhook` (no `CmWebhookEvent` record is created). This separates **logging** from **processing**: the flag controls whether events are persisted, not whether they're handled. #### Postmark Webhook Credentials The built-in Postmark webhook integration uses HTTP Basic Auth. Credentials are stored in Rails encrypted credentials. Add the following to your credentials file (`rails credentials:edit`): ```yaml postmark: webhook_username: your_username webhook_password: your_password ``` The controller reads these via `Rails.application.credentials.dig(:postmark, :webhook_username)` and `Rails.application.credentials.dig(:postmark, :webhook_password)`. #### Setting Up the Database Table Run the generator to create the migration: ```bash rails g cm_admin:add_webhook_events rails db:migrate ``` > **Existing installs:** to link webhook events to recipients and capture clicked links (required for per-recipient open/click tracking and the Events tab), also run: > > ```bash > rails g cm_admin:add_webhook_event_recipient > rails db:migrate > ``` > > The generated migration is idempotent — it skips columns that already exist (`recipient_type`/`recipient_id` + index, and `original_link`). > **Note:** Webhook payloads are stored as ActiveStorage attachments (`raw_payload`). Ensure ActiveStorage is installed and configured in your host app (`rails active_storage:install`). #### Host App Services The gem defines `cm_service` for gem-managed services (e.g. `postmark`). Host apps can define their own `host_service` enum for their own webhook sources: ```ruby # config/initializers/cm_admin.rb Rails.application.reloader.to_prepare do CmWebhookEvent.class_eval do enum :host_service, { chatgpt: 0, gemini: 1, gemini_batch: 2 } end end ``` Integer values are scoped per-column — `cm_service` and `host_service` are independent and may use overlapping integers (e.g. `cm_service: 0 = postmark`, `host_service: 0 = chatgpt`). ### Usage #### Base Controller `CmAdmin::WebhooksController` provides the shared flow for all webhook endpoints: **When `enable_webhook_processing` is enabled (default flow):** 1. **Authenticates** the request via the `authenticate_webhook` before_action (override in subclass — unauthenticated requests never create `CmWebhookEvent` records) 2. **Finds** the existing webhook event via `find_existing_webhook_event` (override in subclass) 3. **Creates** one via `create_webhook_event` when no pre-created event exists (override in subclass — e.g. Postmark) 4. **Attaches** the incoming payload via `attach_webhook_payload` (skipped when `raw_payload` is already attached) 5. **Updates** the event status to `received` (only if it isn't already `received` or `processed`) 6. **Enqueues** `ProcessWebhookEventJob` for non-log-only events 7. Returns `200 OK` immediately **When `enable_webhook_processing` is disabled:** 1. No `CmWebhookEvent` record is created 2. `process_webhook` is called — the default implementation is a no-op, so override it in the subclass to process `webhook_payload` synchronously (e.g. Postmark calls `PostmarkWebhookService.process(webhook_payload)`) 3. Returns `200 OK` Host apps should pre-create webhook events before calling external services (e.g. embedding event.id in the callback URL); the controller then finds the existing event via `find_existing_webhook_event`. For gem-managed services like Postmark where no pre-creation is possible, the subclass implements `create_webhook_event` to create the record when the webhook arrives. The job calls `event.process!` which: - Returns early if the event is log-only (no service set) - Calls `perform_cm_processing` (dispatches to `process_#{cm_service}_event`) - Calls `perform_host_processing` (empty by default — override in host app via class_eval) - Updates status to `processed` on success (with `error_message` set from `processing_note` when provided) or `failed` with the raised error message on failure ##### Methods to Override | Method | Required | Description | | --- | --- | --- | | `authenticate_webhook` | Recommended | Verifies the request before anything is logged or processed. Registered as a `before_action` on the base controller — override with your auth logic (HTTP Basic, HMAC signature, etc.) and render/abort to reject. Defaults to no-op. | | `find_existing_webhook_event` | Yes | Returns the pre-created `CmWebhookEvent` or `nil`. Defaults to `nil`. | | `create_webhook_event` | When not pre-created | Creates a `CmWebhookEvent` when `find_existing_webhook_event` returns `nil`. Used by gem-managed services that cannot pre-create (e.g. Postmark). Defaults to `nil`. | | `attach_webhook_payload` | Recommended | Attaches the incoming webhook payload to `raw_payload`. Called after the event is found or created. Default implementation attaches `webhook_payload.to_json` unless `raw_payload` is already attached. Override if you need custom attachment logic. | | `process_webhook` | When logging disabled | Processes the webhook synchronously when `enable_webhook_processing` is `false`. The default implementation enqueues `ProcessWebhookEventJob` for logged events — call `super` to keep async processing. | | `webhook_event_type` | Yes | Returns the event type string. Defaults to `params[:event_type]`. | | `webhook_payload` | Yes | Returns the webhook payload as a hash. Must be implemented by every subclass to explicitly permit safe fields. Stored as a JSON attachment via `raw_payload`. | | `webhook_event_id` | Optional | Returns the external ID for deduplication. Defaults to `nil`. | | `webhook_eventable` | Optional | Returns the associated record (polymorphic). Defaults to `nil`. | #### Example: Custom Webhook Controller ```ruby module CmAdmin class StripeWebhooksController < WebhooksController private # authenticate_webhook is already wired as a before_action by the base # controller — just override the method with your auth logic. def authenticate_webhook # Your authentication logic here end def find_existing_webhook_event ::CmWebhookEvent.find_by(host_service: :stripe, event_id: webhook_event_id) end def webhook_event_type params[:type] end def webhook_event_id request.headers['Stripe-Idempotency-Key'] end end end ``` #### Pre-create Pattern (Host App) Host apps pre-create webhook events before calling external services, embedding the event ID in the callback URL. When the webhook arrives, the controller finds the existing event and processes it: ```ruby # Before calling the external service: event = CmWebhookEvent.create!( host_service: :chatgpt, event_type: 'BrightData', event_id: 'evt_123', status: :created ) event.raw_payload.attach( io: StringIO.new('{}'), filename: 'payload.json', content_type: 'application/json' ) # In the webhook controller: module CmAdmin class BrightDataWebhooksController < WebhooksController private def find_existing_webhook_event ::CmWebhookEvent.find_by(event_id: params[:event_id]) end def webhook_event_type 'BrightData' end end end ``` #### Routing Webhook endpoints are namespaced under `webhooks`: ```ruby # config/routes.rb (inside CmAdmin::Engine.routes.draw) scope :webhooks do post ':service/:event_id', to: CmAdmin::WebhookRouter, as: :webhook_event post ':service', to: CmAdmin::WebhookRouter, as: :webhook end ``` `CmAdmin::WebhookRouter` resolves `:service` to a controller by convention — `service=postmark` dispatches to `CmAdmin::PostmarkWebhooksController#receive`, `service=bright_data` to `CmAdmin::BrightDataWebhooksController#receive` — and responds with `404` when no matching `WebhooksController` subclass exists. The optional `:event_id` segment is exposed as `params[:event_id]`, which pre-create flows use to look up the event. Any `CmAdmin::XxxWebhooksController < CmAdmin::WebhooksController` is therefore automatically reachable at `POST /cm_admin/webhooks/xxx` (and `POST /cm_admin/webhooks/xxx/:event_id`) — no host-app route needed. Add a custom route only when you need a non-conventional path: ```ruby # config/routes.rb (host app) post '/webhook/bright_data/:event_id', to: 'cm_admin/bright_data_webhooks#receive' ``` #### Built-in Postmark Integration CM Admin includes a `PostmarkWebhooksController` that inherits from `WebhooksController`. It: - Authenticates using HTTP Basic Auth with credentials from Rails encrypted credentials - Validates the presence of `RecordType` parameter - Creates the webhook event with `cm_service: :postmark` and `status: :created` (Postmark sends separate webhooks per recipient for the same `MessageID`, so events are not deduped by `MessageID` alone; identical redeliveries — same message, type, recipient and event timestamp — resolve to the original event via `find_existing_webhook_event`, so replayed webhooks don't double-count opens and clicks) - Stores `MessageID` as the `event_id` - Links to the associated `CmEmailLog` via polymorphic `eventable` (found by `MessageID`) - Links the **recipient** the event belongs to: the log's `record` is used when it responds to `email` and matches the payload's `Recipient`/`Email` — events for CC/BCC recipients of the same message stay unlinked (`recipient: nil`) so they're never attributed to the To recipient - Stores the clicked URL in `original_link` for Click events - Permits the full payload for storage: message/delivery/bounce fields, `FirstOpen`, `OriginalLink`, `ClickLocation`, `UserAgent`, `Platform`, and the nested `Client`/`OS`/`Geo` objects - When logging is disabled, processes inline via `PostmarkWebhookService.process(webhook_payload)` The route is available at `POST /cm_admin/webhooks/postmark` (via the generic webhook router). ### CmWebhookEvent Model The `CmWebhookEvent` model stores the following fields: | Field | Type | Description | | --- | --- | --- | | `cm_service` | integer (enum) | Gem-managed service (e.g. `postmark: 0`) | | `host_service` | integer (enum) | Host-app service — defined by host app | | `event_type` | string | Event type from the webhook payload (required) | | `raw_payload` | ActiveStorage attachment | Full webhook payload stored as a file attachment | | `status` | integer (enum) | `created` (0), `received` (1), `processed` (2), or `failed` (3) | | `event_id` | string | External ID for deduplication | | `uuid` | string | Stable identifier assigned on creation (`SecureRandom`, backed by a unique index) | | `error_message` | text | Error message if processing failed | | `processed_at` | datetime | When the webhook was processed | | `eventable` | polymorphic | Optional associated record (e.g. the `CmEmailLog` the webhook arrived for) | | `recipient` | polymorphic | Optional recipient the event belongs to (e.g. `EmailRecipient`) — populated at receipt by matching the payload's recipient email against the log's record | | `original_link` | string | For Click events, the clicked URL (`OriginalLink`), denormalized at receipt so clicks can be grouped and counted in SQL | The `payload` method reads and parses the attachment on demand (cached per instance): ```ruby event.payload # => { "MessageID" => "...", "RecordType" => "Delivery", ... } ``` There are no unique constraints on incoming webhook events (the only unique index is on `uuid`) — each incoming webhook is logged as a separate record. Processing idempotency (e.g. updating the same `CmEmailLog` recipient status for duplicate Postmark webhooks) must be handled by the service-specific processing logic (`PostmarkWebhookService` → `update_recipient_delivery_status`). #### Processing Dispatch When `process!` is called (via `ProcessWebhookEventJob`): 1. Returns early if the event is log-only (neither `cm_service` nor `host_service` set) 2. **perform_cm_processing** — if `cm_service` is set, calls `process_#{cm_service}_event` (e.g. `process_postmark_event`) 3. **perform_host_processing** — empty by default; host apps override via class_eval to add app-specific processing 4. Updates status to `processed` on success or `failed` on error #### Log-only Events Events with neither `cm_service` nor `host_service` set are treated as log-only — they record the webhook receipt and payload without any processing. The `process!` method returns early and the status stays at `received`. This is useful for host apps that handle processing separately and just want the audit trail in the admin UI. ### Admin UI When webhook logging is enabled, a **Webhook Events** section appears in the CM Admin sidebar with: - **Index view** — filterable by search (ID, UUID, event type, event ID, error), status, service, event type, and created date - **Show view** — detailed view with UUID, payload, error information, and processing timestamps - **Status badges** — color-coded: gray (created), blue (received), green (processed), red (failed) --- ## CmAdmin Date & Date-Time Display Format Configuration Configure how dates and date-times are displayed across the **cm-admin** interface. --- ### 1. Configuration In `cm_admin.rb` (initializer): ```ruby CmAdmin.configure do |config| config.display_date_format = 'd-m-Y' # Default: 'd-m-Y' config.display_datetime_format = 'd-m-Y H:i' # Default: 'd-m-Y H:i' end ``` Use any format supported by [flatpickr](https://flatpickr.js.org/formatting/). --- ### 2. How It Works * Formats are exposed as JavaScript variables: ```js window.cmAdminConfig.displayDateFormat window.cmAdminConfig.displayDateTimeFormat ``` * Used by flatpickr to render pickers consistently. --- ### 3. Example To use format `Y/m/d` for date and `Y/m/d H:i` for date-time: ```ruby CmAdmin.configure do |config| config.display_date_format = 'Y/m/d' config.display_datetime_format = 'Y/m/d H:i' end ``` --- ### 4. Notes * If not set, defaults will apply. (See above) --- ### 5. Year Select Dropdown By default, date pickers include a year-select dropdown in the month navigation, making it easy to jump to years far in the past or future. To use flatpickr's **default** year navigation for a specific field instead, set the `data-default-year-selector` attribute on the input: ```ruby form_field :birth_date, input_type: :date, html_attrs: { data: { default_year_selector: true } } ``` The default year navigation lets users type any year in the month header. Unlike the year-select dropdown, it is not limited to a ±50-year window, so set `data-min-date` / `data-max-date` (ISO `YYYY-MM-DD`) if the field needs an explicit range. --- ### 6. References - [flatpickr Date Formatting Docs](https://flatpickr.js.org/formatting/) - [Ruby Date and Time Formats](https://ruby-doc.org/3.4/Time.html#method-i-strftime) --- --- ## cmSelect Integration 🎯 This module provides a wrapper around TomSelect for creating dynamic select dropdowns with Fetch support. :::warning Important **With cmSelect, `ajax_url` is changed to `options_url`** ::: ### Overview `window.cmSelect` is a global function that initializes TomSelect on a given element with optional fetch configuration for fetching options dynamically. ### Basic Usage #### Manual Initialization 1. Pass element to `window.cmSelect`, like `window.cmSelect(document.querySelector("#my-select"), ...);` 2. Pass a selector to `window.cmSelect`, like `window.cmSelect("#my-select", ...);` 3. It works on a single select element at a time. 4. Default search parameters is `search` 5. To set the value of the select manually from JavaScript, use `element.tomselect.setValue(value)` ```javascript window.cmSelect(document.querySelector("#my-select")); window.cmSelect("#my-select"); ``` ### Fetch Configuration #### Basic Fetch Setup ```javascript window.cmSelect(element, { url: "/cm_admin/users/options" }); ``` #### Full Options ```javascript window.cmSelect(element, { url: "/cm_admin/users/options", queryData: { some_id: 3, some_val: 5 }, onSuccess: function (data) { console.log("Data loaded:", data); }, onError: function (error) { console.error("Failed to load:", error); } }); ``` ### Options Reference | Option | Type | Description | | -------------------- | ---------- | --------------------------------------------------------------------------------------------------- | | `url` | `String` | The endpoint URL to fetch options from. When provided, enables Fetch mode. | | `queryData` | `Object` | Additional query parameters to append to the URL. | | `onSuccess` | `Function` | Callback function called with the response data on successful fetch. | | `onError` | `Function` | Callback function called with the error on fetch failure. | | `reinitializeSelect` | `Boolean` | Default: `true`. Whether to reinitialize the TomSelect instance (destroy current and reinitialize). | ### Response Convention The server endpoint **must** return an array of hashes with `id` and `title` keys: | Key | Type | Description | | ------- | ----------------- | ------------------------------------ | | `id` | `String`/`Number` | The value to be stored on selection. | | `title` | `String` | The label displayed in the dropdown. | **Example Response:** ```json [ { "id": 1, "title": "User One" }, { "id": 2, "title": "User Two" }, { "id": 3, "title": "User Three" } ] ``` **Ruby Example:** ```ruby users.map { |user| { id: user.id, title: user.name } } ``` 💡 **Note**: If a TomSelect instance already exists on the element, it will be destroyed and re-initialized with the new configuration. --- ## Comment and Mention 💬 ### Overview The Comment and Mention feature allows users to add comments on any model's show page and mention users using the `@` trigger. Powered by **Lexxy**, a rich text editor with @-mention autocomplete support. --- ### Quick Start #### 1. Generate Migrations ```bash rails g cm_admin:add_comments_with_mentions rails db:migrate ``` Creates two tables: - `cm_comments` - Stores comments - `cm_user_mentions` - Tracks mentions #### 2. Set Up Commentable Model Add the comments association to any model: ```ruby class Order < ApplicationRecord has_many :comments, as: :commentable, dependent: :destroy, class_name: 'CmComment' end ``` #### 3. Configure Comments Tab ```ruby cm_admin do comments_tab mentions_model: ::User cm_index do column :id # other columns end end ``` --- ### Configuration #### `comments_tab` Options | Option | Type | Required | Default | Description | | ---------------- | --------- | -------- | ------- | ---------------------------------------------- | | `mentions_model` | `Class` | **Yes** | - | The model class for @mentions (e.g., `::User`) | | `position` | `Integer` | No | `1` | Tab position on the show page | | `default_scopes` | `Array` | No | `[]` | Scopes to apply on mentions_model query | #### Example with All Options ```ruby comments_tab mentions_model: ::User, position: 2, default_scopes: %i[active verified] ``` --- ### Display Name This is used to display the name of the user in the comment and mentionable dropdown. The system looks for `cm_display_name` on the mentionable model. Falls back to `full_name` if not defined. ```ruby class User < ApplicationRecord def cm_display_name "#{first_name} #{last_name}" end end ``` --- ### Customization #### Custom Mention Partial (Optional) This is used to customize how mentions render in comments. The `CmActionTextAttachable` concern is automatically included for mention functionality. To customize how mentions render in comments, override `to_attachable_partial_path`: ```ruby class User < ApplicationRecord include CmActionTextAttachable def to_attachable_partial_path "users/mentioned" # renders app/views/users/_mentioned.html.slim end end ``` **NOTES:** Default partial path: `cm_admin/main/mentioned` #### Overriding Email Notifications 1. `MentionNotifiable` is a concern that is included in the `CmUserMention` model. It provides the `send_email_to_mentioned_user` method. 2. When a user is mentioned, `MentionNotifiable#send_email_to_mentioned_user` is called automatically. This sends an email to the mentioned user with: - **Subject:** "💬 New Comment on Model-Name" - **Button:** Link to view the comment 3. To customize, create a concern with same name `MentionNotifiable` on application level and override the `send_email_to_mentioned_user` method. ```ruby module MentionNotifiable extend ActiveSupport::Concern included do def send_email_to_mentioned_user subject = "💬 New Comment on Task [#{mentionable.commentable.formatted_id}]" CmAdmin.send_email( to: [mentioned.email, mentionable.commentable.assignees.map(&:email)].flatten.uniq, subject: subject, partial_file_path: 'cm_admin/mailers/comments/mention_notification', partial_locals: { comment: mentionable }, button_text: 'View Comments', button_link: mentionable.cm_link ) end end end ``` To disable email notifications entirely: ```ruby module MentionNotifiable extend ActiveSupport::Concern included do def send_email_to_mentioned_user end end end ``` #### Custom Email Template Override the default partial at on application level: `app/views/cm_admin/mailers/comments/mention_notification.html.slim` --- ### How Lexxy Works Lexxy provides the @-mention functionality: | Feature | Description | | -------------------- | ----------------------------------------------- | | **Trigger** | Type `@` to open autocomplete dropdown | | **Remote Filtering** | Searches mentions_model from server as you type | | **Space Support** | Handles names with spaces (e.g., "John Doe") | When a user is mentioned: 1. Mention renders as a clickable link in the comment 2. `CmUserMention` record is created 3. Email notification sent (if not overridden) --- ### Models Reference #### CmComment | Association | Type | Description | | ------------- | ----------- | ---------------------------- | | `commenter` | Polymorphic | User who created the comment | | `commentable` | Polymorphic | Model being commented on | | `content` | Rich Text | Comment content (ActionText) | #### CmUserMention | Association | Type | Description | | ------------- | ----------- | ------------------------------ | | `mentioned` | Polymorphic | User who was mentioned | | `mentionable` | Polymorphic | Comment containing the mention | --- ### Requirements - **ActionText** must be installed - **Current.user** must be set (used as commenter) - **paper_trail** gem (both models include `has_paper_trail`) --- ## 🕒 **Cron Job Setup Documentation** ### 📌 Overview The **CM Cron Job** feature allows you to manage scheduled tasks directly within the `cm-admin` interface. You can **create**, **edit**, **enable**, **disable**, **execute**, and **view logs** for each cron job from a user-friendly dashboard. --- ### ✨ Features * 🔍 **Filters**: Search cron jobs by: * Name * Status * Last run time * Last run status * 📊 **Sortable Columns**: * Name * Updated time * Created time * ⚙️ **Custom Actions**: * ✅ Enable or ❌ Disable a cron job * ▶️ Manually execute a cron job * 🔎 **Detailed Views**: * Job information: name, cron string, command, status, last run time, output * Access logs for each run --- ### 🛠️ Setup Instructions #### 1. 📦 Run the CMAdmin Scheduler Generator Generate the necessary files: ```bash rails g cm_admin:add_scheduler ``` --- #### 2. 🧱 Ensure Required Models Exist Your application must include the following models: * `CmCronJob` * `CmCronJobLog` --- #### 3. 🔗 Integrate Sidekiq 1. Run database migrations: ```bash rails db:migrate ``` 2. Start **Sidekiq** to enable background processing. > 💡 **Note**: > > * All `cm_cron` jobs are **removed from Sidekiq** when Sidekiq is shut down. > * When Sidekiq is started, all **enabled** cron jobs will be **automatically re-added** to the queue. --- #### 4. 🧩 Use Custom Actions Available via the `CmAdmin::CmCronJob` concern: * 🚫 **Disable Job** – Deactivates an active job. * 🟢 **Enable Job** – Activates a disabled job. * 🔄 **Execute Job** – Runs a job immediately. * 🗑️ **Delete Job** – Removes the job permanently. > ⚠️ **Note**: Newly created jobs are disabled by default. Use the **Enable** button to activate them. --- #### 5. 📄 View Cron Job Logs Each job’s **Logs** tab includes: * 🕑 Start and completion timestamps * 📈 Execution status * 🔍 Links to detailed log output --- ### 🧪 Example Usage #### ➕ Add a New Cron Job 1. Navigate to the **Add Cron Job** page. 2. Provide the following details: * 🏷️ **Name**: A unique identifier for the job. * 🕰️ **Cron String**: Set the schedule using a valid cron expression. Common presets: * `@hourly` – Every hour * `@daily` – Every day * `@weekly` – Every week * `@monthly` – Every month * `@yearly` – Every year * 💻 **Command**: The shell command to be executed. 3. Click **Save** ✅ to create the job. --- #### ✏️ Edit an Existing Cron Job 1. Open the **Edit Cron Job** page for the desired job. 2. Modify the necessary fields. 3. Click **Save** 💾 to update the job. --- #### 📜 View Job Logs 1. Visit the **Logs** tab on a cron job’s detail page. 2. Review: * ⏱️ Start and end times * 📊 Status of execution * 🔗 Link to output logs --- ### ✅ Conclusion The **CM Cron Job** feature in `cm-admin` empowers you to manage scheduled tasks with confidence. With detailed controls, logging, and Sidekiq support, you can automate background operations efficiently and reliably. 🚀 --- ## CSS Bundler ### Overview CM Admin includes a CSS bundling system that compiles Sass/SCSS stylesheets. The bundler handles both the core CM Admin stylesheets and your application's custom styles The system is built around a shared helper module `CmAdminCssBundler` that provides common functionality for running npm-based CSS build processes. ### How It Works The CSS bundler follows these steps: 1. **Install dependencies** — Runs `npm install` in the CM Admin engine directory 2. **Execute build script** — Runs the specified npm script (e.g., `build:css`, `watch:css`) 3. **Compile stylesheets** — Uses Sass to compile SCSS files to CSS with optimized flags ### Application Setup #### Step 1 — Ensure application stylesheet exists Create or update your main application stylesheet: ```scss // app/assets/stylesheets/application.scss ``` **NOTES**: 1. File should be scss 2. No File on application should have, remove from `app/assets/stylesheets/application.scss` and `cm_admin/app/assets/stylesheets/cm_admin/custom.css` ```css *= require_tree . *= require_self ``` 3. you can include any other stylesheets using `@import` directive in application.scss, it is the entry point for all your stylesheets. #### Step 2 — Remove sassc-rails gem Remove the `sassc-rails` gem from your Gemfile since CM Admin's CSS bundler handles Sass compilation: ```ruby # Gemfile # Remove this line gem 'sassc-rails' ``` Then run `bundle install` to update your dependencies. #### Step 3 — Remove stylesheet inclusion from manifest.js ```javascript // app/assets/config/manifest.js //= link_directory ../stylesheets .css ``` #### Step 4 — Add CSS build to manifest.js ```javascript // app/assets/config/manifest.js //= link_tree ../builds ``` #### Step 5 - Add .keep file to builds directory ```bash # Create .keep file to ensure directory is tracked by git touch app/assets/builds/.keep ``` #### Step 6 - Update gitignore ```bash /app/assets/builds/* !/app/assets/builds/.keep ``` ### Custom Layout CSS Files If your application uses multiple layouts (e.g., a separate layout for public-facing pages, landing pages, or embedded views), you can register additional SCSS files to be compiled alongside the default CM Admin stylesheets. #### Configuration Add the `layout_css_files` option in your CM Admin initializer: ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| config.layout = 'admin' config.layout_css_files = ['public.scss', 'landing.scss'] end ``` Each entry should be a path **relative to** `app/assets/stylesheets/`. For example, `'public.scss'` maps to `app/assets/stylesheets/public.scss`. #### How It Works During the CSS build, each file listed in `layout_css_files` is compiled from its source location to `app/assets/builds/` with the same base name: | Source | Output | | ------------------------------------- | ------------------------------- | | `app/assets/stylesheets/public.scss` | `app/assets/builds/public.css` | | `app/assets/stylesheets/landing.scss` | `app/assets/builds/landing.css` | These files are compiled using the same Sass flags and load paths as the core CM Admin stylesheets, so you can use `@import` directives referencing CM Admin or node module dependencies. #### Subdirectory Support You can also point to files inside subdirectories: ```ruby config.layout_css_files = ['layouts/public.scss', 'layouts/embedded.scss'] ``` | Source | Output | | ---------------------------------------------- | -------------------------------- | | `app/assets/stylesheets/layouts/public.scss` | `app/assets/builds/public.css` | | `app/assets/stylesheets/layouts/embedded.scss` | `app/assets/builds/embedded.css` | > **Note:** The output file name is derived from the base name only, regardless of subdirectory depth. ### Stylesheet Import Paths If your application depends on stylesheets provided by Ruby gems (e.g., `bootstrap`, `select2-rails`, `intl-tel-input-rails`), you can register their stylesheet directories so the cm-admin Sass build can resolve `@import`/`@use` directives. #### Configuration Add the `stylesheet_import_paths` option in your CM Admin initializer: ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| config.stylesheet_import_paths = [ "#{Gem.loaded_specs['bootstrap'].full_gem_path}/assets/stylesheets", "#{Gem.loaded_specs['select2-rails'].full_gem_path}/vendor/assets/stylesheets", "#{Gem.loaded_specs['intl-tel-input-rails'].full_gem_path}/app/assets/stylesheets" ] end ``` #### How It Works Each entry is a directory path. During the Sass build, each directory is passed to the compiler as a `--load-path=` flag, so any `@import` in `application.scss` or `layout_css_files` can resolve partials located in those directories. > **Note:** These are paths, not full flags. For example, `assets/stylesheets` not `--load-path=assets/stylesheets`. ### Rake Commands #### Build Commands ```bash # Build all CSS files for production (already enhanced in assets:precompile rake task) rake cm_admin:scss:build ``` These commands: - Compile `cm_admin.css.scss` → `cm_admin.css` - Compile `cm_session.css.scss` → `cm_session.css` - Compile `application.scss` → `application.css` - Place outputs in `app/assets/builds/` #### Watch Commands ```bash # Watch for changes and auto-recompile during development rake cm_admin:scss:watch ``` These commands start the Sass compiler in watch mode, automatically recompiling when source files change. #### Integration with Assets Precompile The CSS build automatically runs during `assets:precompile`: ```bash rake assets:precompile ``` This ensures your CSS is always up-to-date when deploying to production. ### File Structure ``` your-app/ ├── app/assets/ │ ├── builds/ # Compiled CSS output (generated) │ │ ├── cm_admin/ │ │ │ ├── cm_admin.css │ │ │ └── cm_session.css │ │ └── application.css ``` ### Troubleshooting #### Common Issues **"npm install failed"** - Ensure Node.js and npm are installed - Check that the CM Admin engine directory exists and is accessible **Compiled CSS not updating** - Run `rake cm_admin:scss:build` manually to force rebuild - Check file permissions in `app/assets/builds/` --- ## Custom Action ### Overview Custom actions let you add your own buttons, links, modals, and pages to a model beyond the default `index`, `show`, `new`, `edit`, and `destroy` actions. They are defined with the `custom_action` DSL method inside a model's cm-admin configuration and can run arbitrary Ruby code against a record (or the whole collection). Typical use cases: - **State transitions** — approve, archive, publish, or soft-delete a record. - **Quick edits** — update a subset of fields through a modal or dedicated page. - **Exports / bulk fetches** — trigger a collection-level operation. - **External links** — send the user to another URL related to the record. ### Syntax ```ruby custom_action name: 'action_name', route_type: 'member', verb: 'patch', path: ':id/action_name', display_type: :button do record = ::YourModel.find(params[:id]) # perform the operation record end ``` The block receives access to `params` and must return the affected record (or collection). The returned object is used to render the response and to evaluate `success_message` / `error_message`. ### Options | Option | Type | Description | | --- | --- | --- | | `name` | String | Unique identifier for the action. Used to build the route and controller method. | | `display_name` | String | Human-friendly label shown on the button/link. Falls back to `name.titleize`. | | `verb` | String | HTTP verb: `get`, `post`, `put`, `patch`, or `delete`. | | `path` | String | URL path. Use `:id` for member routes (e.g. `':id/approve'`). | | `route_type` | String | `member` (operates on a single record) or `collection` (operates on many). | | `display_type` | Symbol | How the action is rendered. See [Display types](#display-types). | | `display_if` | Proc | Lambda receiving the record; return `true` to show the action. | | `icon_name` | String | Font Awesome icon class, e.g. `'fa-regular fa-circle-check'`. | | `icon_style` | String | Inline icon style, e.g. `'--fa-primary-color: #ff6b6b;'`. | | `modal_configuration` | Hash | Modal `title`, `description`, and `confirmation_text` for modal display types. | | `page_title` | String | Title shown for `:page` / `:form_page` display types. | | `page_description` | String | Description shown for `:page` / `:form_page` display types. | | `group_name` | String | Groups actions together in the dropdown (see [Group Action](./GroupAction.md)). | | `show_on_index` | Boolean | Whether the action appears in the index row dropdown. Defaults to `true`. | | `redirect_to` | Proc | Lambda returning a URL to redirect to (used by `:link`). | | `success_message` | Proc | Lambda receiving the record; returns an alert Hash `{ header:, body:, icon: }`. | | `error_message` | Proc | Lambda receiving the record and its errors; returns an alert Hash `{ header:, body:, icon: }`. | | `alert_type` | Symbol | Success alert style: `:flash` (default) or `:banner`. | ### Display types The `display_type` option controls how the action is presented: - **`:button`** — A button that submits directly and runs the block. Best for one-click state changes. - **`:modal`** — Shows a confirmation modal before submitting. Configure copy via `modal_configuration`. - **`:form_modal`** — Opens a modal containing a form. Requires a `form` block and an `on_submit` block. - **`:page`** — Renders a dedicated page. Use `page_title` / `page_description`. - **`:form_page`** — A full-page form. Requires a `form` block and an `on_submit` block. - **`:link`** — Renders a link to an external/related URL via `redirect_to`. - **`:icon_only`** — Renders only the icon with no label. ### Examples #### Button action Runs immediately when clicked. Here `display_if` hides the action unless the record is a draft. ```ruby custom_action name: 'activate', display_name: 'Activate', route_type: 'member', verb: 'post', icon_name: 'fa-regular fa-power-off', path: ':id/activate', display_type: :button, display_if: ->(df) { !df.is_active? } do demo_form = ::DemoForm.find(params[:id]) demo_form.update!(is_active: true) demo_form end ``` #### Modal action Prompts the user to confirm before running the block. ```ruby custom_action name: 'approve', display_name: 'Approve', route_type: 'member', verb: 'patch', icon_name: 'fa-regular fa-circle-check', path: ':id/approve', display_type: :modal, display_if: lambda(&:draft?), modal_configuration: { title: 'Approve Demo Form', description: 'Are you sure you want to approve this demo form?', confirmation_text: 'Approve' } do demo_form = ::DemoForm.find(params[:id]) demo_form.approved! demo_form end ``` #### Form modal action Collects input through a form rendered in a modal. Requires both a `form` block (defining the fields) and an `on_submit` block (handling the update). ```ruby custom_action name: 'assign_user', display_name: 'Assign User', verb: 'post', route_type: 'member', icon_name: 'fa-regular fa-user', display_type: :form_modal, path: ':id/assign_user', modal_configuration: { title: 'Select User', confirmation_text: 'Assign' } do form do cm_section '' do alert_box body: 'This will assign a new user to the demo form.', type: :info form_field :user_id, input_type: :radio_button_group, helper_method: :select_options_for_user, label: 'User' end end on_submit do @demo_form = ::DemoForm.find(params[:id]) @demo_form.update!(user_id: params.dig(:demo_form, :user_id)) @demo_form end end ``` > **Note:** For `:form_modal` and `:form_page`, the `form` block is mandatory. Submitted params are nested under the model name, e.g. `params[:demo_form][:user_id]`. #### Collection action Use `route_type: 'collection'` for actions that operate on the whole collection rather than a single record. These paths do not include `:id`. ```ruby custom_action name: 'export_all', display_name: 'Export All', route_type: 'collection', verb: 'get', icon_name: 'fa-regular fa-download', path: 'export_all', display_type: :button do ::DemoForm.all end ``` #### Styled action Customize the icon appearance with `icon_style`. ```ruby custom_action name: 'styled_action', display_name: 'Styled Action', route_type: 'member', verb: 'post', icon_name: 'fa-regular fa-palette', icon_style: '--fa-primary-color: #ff6b6b; --fa-secondary-color: #4ecdc4;', path: ':id/styled_action', display_type: :button do demo_form = ::DemoForm.find(params[:id]) demo_form end ``` #### Custom success and error messages `success_message` and `error_message` are lambdas that return an alert Hash with `:header`, `:body`, and `:icon` keys. `success_message` receives the record; `error_message` receives the record and its `errors`. Any key you omit falls back to a sensible default. ```ruby custom_action name: 'approve', display_name: 'Approve', route_type: 'member', verb: 'patch', icon_name: 'fa-regular fa-circle-check', path: ':id/approve', display_type: :button, success_message: lambda { |demo_form| { header: 'Approved', body: "#{demo_form.name} was approved.", icon: 'fa fa-check-circle' } }, error_message: lambda { |_demo_form, errors| { header: 'Approval failed', body: errors.full_messages.to_sentence, icon: 'fa fa-circle-xmark' } } do demo_form = ::DemoForm.find(params[:id]) demo_form.approved! demo_form end ``` ### Related - [Group Action](./GroupAction.md) — group multiple custom actions under a common heading. - [Bulk Action](./BulkAction.md) — run an action across multiple selected records. - [Adding Alert](./AddingAlert.md) — configure success and error alerts. --- ## Custom Filter Method ### Overview We can add a custom filter method to filter records based on specific criteria. This is useful when the default filtering options do not meet the requirements. The custom filter method can be defined using the `filter_with` option. ### Usage The custom filter method is implemented using the `filter` dsl_method with the `filter_with` option. Below are the key elements and their usage: #### Syntax ```ruby filter :filter_name, :filter_type, filter_with: :custom_filter_method ``` #### Scope signature | Filter type | Scope arguments | | --------------------------------- | -------------------------- | | `:search` | `(value)` | | `:single_select`, `:multi_select` | `(value, condition = nil)` | | `:date`, `:range` | `(value, condition = nil)` | | `:string` | `(value, condition = nil)` | `condition` is the operator selected in [Advanced Filters](./AdvancedFilters.md), for example `is_not` or `greater_than`. It is `nil` when the scope is called from the standard filter bar. For date and range filters, `value` is the raw string (`"2026-01-01 to 2026-01-31"`, `"18 to 30"`, or a single value such as `"18"`). :::warning Lambda scopes enforce arity. A select, date, range, or string scope that does not accept `condition` raises `ArgumentError`. ::: #### Example **1. For Search:** ```ruby class YourModel < ApplicationRecord scope :custom_filter_method, ->(value) { where('column_name ILIKE ?', "%#{value}%") } end ``` ```ruby filter :filter_name, :search, filter_with: :custom_filter_method ``` **2. For Single / Multi Select:** ```ruby class User < ApplicationRecord scope :by_company_country, lambda { |value, condition = nil| left_joins(:company).ct_dropdown_filter_condition(column: :country, value:, condition:, table: 'companies') } end ``` ```ruby filter :company_country, :multi_select, helper_method: :company_country_options, filter_with: :by_company_country ``` **3. For String:** ```ruby class User < ApplicationRecord scope :by_company_name, lambda { |value, condition = nil| left_joins(:company).ct_string_filter_condition(column: :name, value:, condition:, table: 'companies') } end ``` ```ruby filter :company_name, :string, filter_with: :by_company_name ``` **4. For Date and Range:** ```ruby class User < ApplicationRecord scope :by_company_size, lambda { |value, condition = nil| left_joins(:company).ct_range_filter_condition(column: :employee_count, value:, condition:, table: 'companies') } end ``` ```ruby filter :company_size, :range, filter_with: :by_company_size ``` #### Condition helpers These helpers are available on every ActiveRecord model through `CmAdmin::FilterConditions`. `table:` defaults to the model's own table. | Helper | Handles | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ct_dropdown_filter_condition(column:, value:, condition:, table:)` | `is`, `is_not`, `is_empty`, `is_not_empty`. `cm_empty` maps to `NULL` | | `ct_range_filter_condition(column:, value:, condition:, table:)` | `between`, `not_between`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is`, `is_not`, `is_empty`, `is_not_empty` | | `ct_string_filter_condition(column:, value:, condition:, table:)` | `is`, `is_not`, `contains`, `does_not_contain`, `is_empty`, `is_not_empty`. Case-insensitive, literal matches (wildcards escaped); `is_empty` also matches `''` | If you write the logic by hand instead, handle `is_empty` / `is_not_empty`. They are called with a blank `value`. --- ## Global Search ### Overview Global Search provides a unified search experience across multiple models in your CmAdmin panel. Users can press **⌘ + K** (Mac) or **Ctrl + K** (Windows) to open a search modal from anywhere on the platform. It searches records using the existing `:search` filters on each model's index action, respects Pundit policies and scopes, and returns up to 5 matching records per model. In addition to record results, the search also suggests matching model pages (e.g., typing "user" suggests navigating to the Users index page). ### Features - **Keyboard shortcut** — Open search with ⌘ + K / Ctrl + K from any page. - **Cross-model search** — Search across all configured models in a single query. - **Policy-aware** — Only shows results the current user is authorized to see. - **Concurrent execution** — Searches models in parallel using a configurable thread pool. - **Keyboard navigation** — Navigate results with Arrow keys and press Enter to visit. - **Model suggestions** — Suggests matching model pages alongside record results. - **Customizable subtitle fields** — Control which fields appear below each search result. - **Custom display name** — Define `cm_display_name` on your model to control the result title. ### Setup #### Step 1 — Register models for global search In your CmAdmin initializer (e.g., `config/initializers/cm_admin.rb`), add the models you want to be searchable: ```ruby CmAdmin.configure do |config| config.global_search_models = [User, Post, Order] end ``` > **Note:** The order of models in this array determines the display order of results. #### Step 2 — Ensure a search filter exists Each model must have a `:search` filter defined in its `cm_index` block. Global search uses this filter to query records. ```ruby cm_admin do cm_index do filter %i[email first_name last_name], :search, placeholder: 'Search by name or email' column :first_name column :last_name column :email end end ``` > **Important:** If a model does not have a `:search` filter on its index action, it will be silently skipped during global search. #### Step 3 — Define `cm_display_name` By default, search results display as `"ModelName #id"` (e.g., "User #42"). To show a more meaningful title, define `cm_display_name` on your model: ```ruby class User < ApplicationRecord def cm_display_name "#{first_name} #{last_name}" end end ``` ```ruby class Post < ApplicationRecord def cm_display_name title end end ``` #### Step 4 — Customize subtitle fields Each search result can display additional metadata below the title. By default, `global_search_fields` is set to `[:id, :created_at, :updated_at]`. You can override this per model by setting the `global_search_fields` attribute: ```ruby cm_admin do global_search_fields %i[email status created_at] cm_index do filter %i[email first_name], :search column :email column :status, field_type: :badge column :created_at, field_type: :date end end ``` > **Note:** The fields listed in `global_search_fields` must also be defined as columns in `cm_index`. Only columns that exist in the index view and pass their `display_if` condition will be shown. ### Configuration Reference #### `CmAdmin.config.global_search_models` | Property | Type | Default | Description | | ---------------------- | ------- | ------- | ------------------------------------------------------------------------ | | `global_search_models` | `Array` | `[]` | Array of ActiveRecord model classes to include in global search results. | #### `global_search_fields` (per model) | Property | Type | Default | Description | | ---------------------- | ------- | --------------------------------- | -------------------------------------------------------------------- | | `global_search_fields` | `Array` | `[:id, :created_at, :updated_at]` | Array of field identifiers to display as subtitle in search results. | #### Environment Variables | Variable | Default | Description | | -------------------------------- | ------- | ----------------------------------------------------- | | `GLOBAL_SEARCH_THREAD_POOL_SIZE` | `5` | Number of threads used for concurrent model searches. | ### How It Works 1. User opens the search modal via **⌘ + K** / **Ctrl + K** or by clicking the search icon. 2. After typing at least 2 characters, a debounced request (300ms) is sent to the server. 3. The server iterates over `global_search_models` in parallel threads. 4. For each model: - Checks if the user has `index` policy access. - Checks if a `:search` filter is defined on the index action. - Applies policy scopes and default scopes. - Runs the search filter and limits results to 5 records. 5. Results are sorted by the original model order and rendered via Turbo Stream. 6. Additionally, model pages matching the query string are suggested at the bottom. ### Full Example ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| config.global_search_models = [User, Post, Comment] end ``` ```ruby # app/models/user.rb class User < ApplicationRecord def cm_display_name "#{first_name} #{last_name}" end cm_admin do icon_name 'fa fa-user' global_search_fields %i[email created_at] cm_index do filter %i[email first_name last_name], :search, placeholder: 'Search users' column :first_name column :last_name column :email column :created_at, field_type: :date end end end ``` ```ruby # app/models/post.rb class Post < ApplicationRecord def cm_display_name title end cm_admin do icon_name 'fa fa-file-text' global_search_fields %i[status created_at] cm_index do filter %i[title], :search, placeholder: 'Search posts' column :title column :status, field_type: :badge column :created_at, field_type: :date end end end ``` ### Notes - Models without a `:search` filter or without index policy access are automatically excluded. - The search respects all default scopes and action-level scopes configured on the model. - The search modal will not open if another modal is already visible on the page. - The placeholder in the search input automatically shows the first 3 configured model names. --- ## Group Action Documentation coming soon. --- ## Hotwire Breaking Changes ### Links Navigation When Hotwire/Turbo is enabled, links inside Turbo Frames will navigate within the frame by default instead of the full page. **Solution:** Add `data: { turbo_frame: "_top" }` to links that should navigate the full page: ```ruby link_to "View Details", some_path, data: { turbo_frame: "_top" } ``` Alternatively, you can use `target: "_top"` on the turbo-frame element itself if all links should break out of the frame. --- ## Importers CM Admin importers process files uploaded through an `importable` action. Use `CmAdmin::BaseImporter` for standard CSV and Excel row processing, or provide a standalone importer when you need complete control over parsing and execution. ### Configure the import action Add `importable` inside the model's `cm_admin` configuration: ```ruby cm_admin do importable class_name: 'UserImporter', importer_type: :default_importer, sample_file_path: '/user_import.csv' end ``` | Option | Required | Description | | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------- | | `class_name` | Yes | Importer class initialized by CM Admin. | | `importer_type` | Yes | Use `:default_importer` for a `CmAdmin::BaseImporter` subclass or `:custom_importer` for a standalone importer. | | `sample_file_path` | No | Public path to a sample file shown on the import page. | | `route_type` | No | `:collection` by default; use `:member` to import against one record. | | `display_if` | No | Lambda controlling whether the import action is visible. | | `page_configuration` | No | Import page settings such as `title`, `description`, and `submit_button_text`. | The uploaded file is stored as a `CtFileImport` and processed asynchronously by `FileImportProcessorJob`. The importer class must be autoloadable and accept `file_import:` in its initializer. ### Use `CmAdmin::BaseImporter` The base importer provides file download, CSV/TSV/XLS/XLSX parsing, header validation, blank-row skipping, value trimming, per-row error capture, counters, and import status calculation. Create an importer and implement `process_row`: ```ruby class UserImporter < CmAdmin::BaseImporter def self.required_headers %w[email name] end private def process_row(row, _line_number) User.create!(email: row[:email], name: row[:name]) end def identifier_for(row) row[:email] end end ``` - **`required_headers`** — **Optional**. Lists headers that must exist in the first row. Header matching is exact. - **`process_row(row, line_number)`** — Runs once for each non-blank row. `row` supports string and symbol keys. Return a truthy value for success; raise an error or return `nil`/`false` for failure. - **`identifier_for(row)`** — **Optional**. Returns the value shown beside a failed row; the default is `-`. Without transactions, the importer continues after a failed row. Successful rows remain committed and the final status is `:partial_success` when at least one other row succeeds. #### Run the whole import in a transaction Transactions are disabled by default. Opt in by overriding `use_transaction?`: ```ruby class UserImporter < CmAdmin::BaseImporter def self.required_headers %w[email name] end def self.use_transaction? true end private def process_row(row, _line_number) User.create!(email: row[:email], name: row[:name]) end def identifier_for(row) row[:email] end end ``` When enabled, all rows run inside one `ActiveRecord::Base.transaction`. The first failed row stops processing and rolls back database changes made earlier in that transaction. The failed row is still included in the import error report. Use a transaction only when the import must be atomic. Keep it disabled for partial imports or when processing large files where a long-running transaction is undesirable. Transactions only cover database work participating in the same Active Record transaction; files, API calls, jobs, and other external side effects are not rolled back. ### Keep a fully custom importer Use `:custom_importer` to retain the previous standalone approach. CM Admin does not require inheritance and does not control parsing or transactions: ```ruby cm_admin do importable class_name: 'UserImporter', importer_type: :custom_importer, sample_file_path: '/user_import.csv' end ``` The custom class must satisfy this interface: ```ruby class UserImporter InvalidRow = Struct.new(:line_number, :identifier, :errors) attr_reader :invalid_rows, :success_count, :row_count def initialize(file_import:) @file_import = file_import Current.user = file_import.added_by @invalid_rows = [] @success_count = 0 @row_count = 0 end def run! # Download and process @file_import.import_file here. rescue StandardError => e @invalid_rows << InvalidRow.new(1, '-', e.message) end def import_status return :success if invalid_rows.empty? # use partial_success with success_count and row_count if required. :failed end end ``` The processor expects: - **`initialize(file_import:)`** — Receives the persisted `CtFileImport` containing the uploaded `import_file` attachment. - **`run!`** — Performs the import. - **`invalid_rows`** — Returns objects exposing `line_number`, `identifier`, and `errors`. - **`import_status`** — Optional. Return `:success`, `:partial_success`, or `:failed`. Without it, CM Admin returns `:success` when `invalid_rows` is empty and `:failed` otherwise. A custom importer is responsible for file parsing, header validation, error handling, cleanup, transactions, and status semantics. Use it when the base importer's row-oriented workflow does not fit the import. --- ## Lexxy Rich Text Templates 📝 This feature provides AI-powered rich text template integration using Lexxy, allowing users to create reusable prompt templates with category-based filtering and slash command access. ### Overview The Lexxy Rich Text Templates feature introduces: - **CmPrompt Model**: Stores reusable rich text templates with category associations - **Lexxy Integration**: AI-powered slash commands (`/`) in rich text editors - **Category-Based Filtering**: Organize templates by categories for easy access - **Policy Scoping**: Role-based access control for template visibility - **Dynamic Scopes**: Automatic scope methods for category filtering (e.g., `estimation_only`) ### Installation #### 1. Run the Generator ```bash rails generate cm_admin:add_prompts_with_constants ``` This creates two migrations: - `create_cm_prompts` - Creates the prompts table - `create_constant_maps` - Creates the polymorphic association table #### 2. Run Migrations ```bash rails db:migrate ``` #### 3. Add CmPrompt to Included Models Update your CM Admin configuration: ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| config.included_models = [Constant, User, CmRole, DemoForm, CmPlatformSetting, CmSupportTicket, CmCronJob, CmCronJobLog, ApiToken, CmPrompt] # ... other configuration end ``` #### 4. Add Lexxy Template Platform Setting The generator automatically adds a platform setting for the default Lexxy template. You can configure it in the admin panel under **Platform Settings > RichText Templates**. ### Model Structure #### CmPrompt ```ruby class CmPrompt < ApplicationRecord has_rich_text :prompt has_many :categories, as: :mappable, dependent: :destroy, class_name: 'ConstantMap' has_many :constants, through: :categories validates :name, :slug, presence: true, uniqueness: { case_sensitive: false } end ``` **Fields:** - `name` - Template name (required, unique) - `slug` - URL-friendly identifier (auto-generated from name) - `prompt` - Rich text content (ActionText) - `created_by` - User who created the template - `updated_by` - User who last updated the template #### ConstantMap Polymorphic association table linking Constants to any model: ```ruby class ConstantMap < ApplicationRecord belongs_to :constant belongs_to :mappable, polymorphic: true end ``` ### Usage #### Creating Templates 1. Navigate to **Cm Prompts** in the admin panel 2. Click **New Cm Prompt** 3. Fill in the template details: - **Name**: Template name (e.g., "Project Estimation") - **Slug**: Auto-generated, can be customized - **Categories**: Select one or more categories from `cm_prompt_category` constants - **Prompt**: Rich text content with formatting 4. Save the template #### Using Templates in Rich Text Fields When editing any rich text field in the admin panel: 1. Type `/` in the rich text editor 2. A dropdown appears with available templates 3. Select a template to insert its content 4. The template content is inserted as editable text #### Category-Based Filtering Templates can be filtered by category using dynamic scope methods: ```ruby # Filter by specific category CmPrompt.with_category('Estimation') # Dynamic scope method (auto-generated from category name) CmPrompt.estimation_only CmPrompt.documentation_only CmPrompt.reporting_only ``` The `_only` suffix is automatically added to category names to create scope methods. ### Policy Scoping Templates respect role-based access control through policy scopes: ```ruby class CmPromptPolicy < ApplicationPolicy class RichTextTemplateScope < Scope def resolve # Implement your access control logic # Example: Only show templates for user's categories scope.all end end end ``` The policy scope name must follow the pattern: `CmAdmin::{ModelName}PolicyRichTextTemplateScope` ### Platform Settings A new platform setting is added for Lexxy configuration: - **Lexxy Template**: The default Lexxy template to use Access this in **Platform Settings > RichText Templates**. ### API Endpoints #### Rich Text Templates Endpoint ```ruby # lib/cm_admin/constants.rb rich_text_templates: { verb: :get, path: 'rich_text_templates' } ``` This endpoint is used by the Lexxy integration to fetch available templates. ### Technical Details #### Lexxy Integration The Lexxy prompt system is integrated into rich text fields via the `build_lexxy_prompt` helper: ```ruby def build_lexxy_prompt policy_scope_name = "CmAdmin::#{@model.ar_model.name}PolicyRichTextTemplateScope" templates = policy_scope_name.constantize.new(Current.user, ::CmPrompt).resolve template_items = templates.map { |t| build_lexxy_item(t) }.join <<~HTML.html_safe #{template_items} HTML end ``` #### Dynamic Scope Methods The CmPrompt model uses `method_missing` to create dynamic scope methods: ```ruby def self.method_missing(method_name, *args, &) if method_name.to_s.end_with?('_only') category_name = method_name.to_s.gsub('_only', '').titleize with_category(category_name) else super end end ``` This allows calling `CmPrompt.estimation_only` which internally calls `CmPrompt.with_category('Estimation')`. #### Category Association Management Categories are managed through the `category_ids` virtual attribute: ```ruby def category_ids=(ids) @category_ids = Array(ids).reject(&:blank?) end def create_category_associations return if @category_ids.blank? categories.destroy_all @category_ids.each do |category_id| categories.create(constant_id: category_id) end end ``` ### Database Schema #### cm_prompts Table ```ruby create_table :cm_prompts do |t| t.string :name, null: false t.citext :slug, null: false t.references :created_by, foreign_key: { to_table: :users } t.references :updated_by, foreign_key: { to_table: :users } t.timestamps end add_index :cm_prompts, :name, unique: true add_index :cm_prompts, :slug, unique: true ``` #### constant_maps Table ```ruby create_table :constant_maps do |t| t.references :constant, null: false t.references :mappable, polymorphic: true, null: false t.timestamps end add_index :constant_maps, %i[constant_id mappable_id mappable_type], unique: true ``` ### Dependencies This feature requires: - **Lexxy** (~> 0.8) - AI-powered rich text editor integration - **ActionText** - Rails rich text framework - **pg_trgm** PostgreSQL extension (for citext support) ### Troubleshooting #### Templates Not Appearing in Editor 1. **Check Policy Scope**: Ensure the policy scope returns templates for the current user 2. **Verify Categories**: Ensure templates have at least one category assigned 3. **Check Lexxy Configuration**: Verify the Lexxy template platform setting is configured #### Category Scope Methods Not Working 1. **Verify Category Name**: Ensure the category exists in `cm_prompt_category` constants 2. **Check Naming Convention**: Use the `_only` suffix (e.g., `estimation_only`) 3. **Review Category Association**: Ensure templates are properly associated with categories #### Migration Errors If the `constant_maps` table already exists, the migration will skip creation: ```ruby return if table_exists?(:constant_maps) ``` ### Examples #### Creating a Template with Categories ```ruby # In Rails console template = CmPrompt.create!( name: 'Project Estimation Template', slug: 'project-estimation', prompt: '

Project Estimation

Enter your estimation details...

', category_ids: [1, 2] # IDs of cm_prompt_category constants ) ``` #### Filtering Templates by Category ```ruby # Using scope method estimation_templates = CmPrompt.estimation_only # Using with_category documentation_templates = CmPrompt.with_category('Documentation') # Combining with other scopes recent_estimation = CmPrompt.estimation_only.order(created_at: :desc).limit(10) ``` #### Accessing Template Categories ```ruby template = CmPrompt.first template.category_names # => "Estimation, Documentation" template.category_ids # => [1, 2] ``` ### Future Enhancements Potential improvements for this feature: - Template versioning with history tracking - Template preview before insertion - Template variables/placeholders support - Template sharing between users - Template analytics (usage tracking) --- ## Lisiting of Select Two using Ajax 🅰️ ### Method 1: Using Ajax URL for Form Field This method demonstrates how to create a form field with a single select input that uses an Ajax URL to fetch data. #### Adding Ajax URL Parameter The below form field creates a single select input for optional languages. It uses an Ajax URL to fetch the language options dynamically. ```ruby form_field :optional_language_id, input_type: :single_select, label: 'Optional Language', ajax_url: :fetch_optional_language ``` #### Custom Action for Fetching Data This custom action retrieves data from the database and returns it as a JSON response. Example below, is specifically designed to fetch optional language data based on a search parameter. **Location**: Define this custom action in the index section of your configuration. ```ruby custom_action name: 'fetch_optional_language', route_type: :collection, verb: 'get', path: '/fetch_optional_languages', display_type: :route do @optional_language = ::Constant.search_constant_by_name(params[:search],"optional_language") { results: @optional_language.map { |language| { id: language.id, text: language.name.titleize } }, pagination: { more: false } } end ``` This custom action is used to fetch optional languages based on a search parameter. It returns a JSON response with the language results and pagination information. ### Method 2: Using HTML Attributes and JavaScript for Form Field This method shows how to create a form field with custom HTML attributes and use JavaScript to implement the select functionality. #### Adding Custom HTML Attributes ```ruby form_field :optional_language_id, input_type: :single_select, label: 'Optional Language', html_attrs: { 'data-behaviour': 'optional_language-select' } ``` This form field creates a single select input for optional languages with a custom data attribute for JavaScript targeting. #### Custom Action for Fetching Data This custom action retrieves data from the database and returns it as a JSON response. Example below, is specifically designed to fetch optional language data based on a search parameter. **Location**: Define this custom action in the index section of your configuration. ```ruby custom_action name: 'fetch_optional_language', route_type: :collection, verb: 'get', path: '/fetch_optional_languages', display_type: :route do @optional_language = ::Constant.search_constant(params[:search], params[:class],"optional_language") { results: @optional_language.map { |language| { id: language.id, text: language.name.titleize } }, pagination: { more: false } } end ``` #### JavaScript Function for Select2 Implementation This JavaScript function initializes a Select2 dropdown for the optional language select field. It configures Ajax loading of options, result processing, and pagination. We can also pass additional parameters to the server using the `data` attribute. **Location**: Define this JavaScript function in your application's JavaScript file. ```javascript function selectTwoAjaxCaller(){ const $event = $('select[data-behaviour="optional_language-select"]'); $event.select2({ theme: "bootstrap-5", ajax: { url: "fetch_optional_languages", dataType: 'json', delay: 250, data: function (params) { return { search: params.term, class: 12 }; }, processResults: function (data, params) { return { results: data.results }; }, }, minimumInputLength: 0, }); } ``` This JavaScript function initializes a Select2 dropdown for the optional language select field. It configures Ajax loading of options, result processing, and pagination. --- ## Population of items in drop-downs 🔽 This document provides details on various methods used in our project. Each method is explained with its purpose and usage examples. ### Method 1: Using Collection / Helper Method for Transport Type The example below demonstrates how to create a section with a single-select field using collection. ```ruby cm_section 'Mode Of Transport' do form_field :transport_type_id, input_type: :single_select, collection: :mode_of_transport end ``` 💡Note: You need to set mode_transport array like this: ```ruby mode_of_transport = [['Vehicle','vehicle'],['Bike', 'bike']] ``` Or you can use a helper method to fetch the data: ```ruby cm_section 'Mode Of Transport' do form_field :transport_type_id, input_type: :single_select, helper_method: :mode_of_transport_method end ``` 💡Note: This should be defined in the helper module: ```ruby def mode_of_transport_method Constant.where(type:"transport_type").all.map { |item| [item.full_name, item.id] } end ``` ### Method 2: Custom Action for Transport Type The example below demonstrates how to create a section with a single-select field using a custom action. This custom action is designed to fetch transport type data from the database and automatically populate the dropdown with the appropriate values when triggered. 💡 Note: The retrieved data is used to populate the target_value field. **Location**: This custom action should be defined in the index section of your configuration. ```ruby custom_action name: 'transport_type', route_type: :collection, verb: 'get', path: '/transport_type', display_type: :route do transport_type = Constant.where(type: :transport_type).all.map { |item| [item.full_name, item.id] } { fields: [ { target_type: :select, target_value: { table: 'student', field_name: 'transport_type_id', field_value: transport_type } } ] } end ``` 💡Note: This needs to be added as a trigger variable for populating the fields: ```ruby form_field :route_id, input_type: :single_select, target: { action_name: :fetch_transport_type } ``` --- ## MCP Server cm-admin ships with a built-in [Model Context Protocol](https://modelcontextprotocol.io) server that exposes your admin-registered models to MCP-compatible AI clients (Claude Desktop, Cursor, Windsurf, GitHub Copilot Chat, etc.) over JSON-RPC. The current release (**v1**) is intentionally minimal and read-only: every cm-admin model gets two tools — `_list` and `_show` — plus a global `list_models` discovery tool. Authentication is done with Bearer Personal Access Tokens (PATs), and every tool call goes through the same Pundit policy scopes that gate the cm-admin web UI, so MCP cannot leak rows that the user could not otherwise see. --- ### What you get | Tool | Description | | --------------- | ----------------------------------------------------------------------------------------------------------- | | `list_models` | Returns every MCP-enabled cm-admin model and the actions exposed for each. Useful for discovery. | | `fetch_options` | Fetches options for select filters given a helper method name. | | `_list` | Paginated, sortable, filterable list of `` records. Honors `CmAdmin::PolicyIndexScope`. | | `_show` | Full record by ID, including nested-field associations and tab data. Honors `CmAdmin::Policy#show?`. | Tool names are derived from `Model.name.underscore` (e.g. `User` → `user_list`, `ApiToken` → `api_token_show`). Names longer than 64 characters are truncated while preserving the action suffix. --- ### Setup #### 1. Enable MCP globally ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| # ...your existing config... config.mcp_enabled = true end ``` When `mcp_enabled` is `true`: - The endpoint is served by `CmAdmin::McpController` at `/mcp` (e.g. `/cm_admin/mcp` if you mount cm-admin at `/cm_admin`). - Models with `mcp_actions` explicitly called in their cm_admin block are MCP-enabled with `list` + `show`. When `mcp_enabled` is `false` (default), the route is not drawn and no MCP endpoint is exposed. #### 2. Configure server identity The MCP server reports itself using your Rails project settings so different apps mounting cm-admin can be distinguished by clients: ```ruby # config/application.rb config.x.project_settings.name = 'My Cool App' # → MCP server title config.x.project_settings.slug = 'my_cool_app' # → MCP server name ``` Falls back to `CmAdmin` / `cm_admin` when not set. #### 3. Provide domain context (system prompt) The MCP server sends an `instructions` field to the AI client during the `initialize` handshake. This is the system prompt the AI reads before it calls any tool, so it is the right place to give it domain-specific knowledge about your data model, business semantics, and known limitations. Set `config.mcp_system_prompt` to inject your own context into every AI session: ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| config.mcp_enabled = true config.mcp_system_prompt = <<~PROMPT You are reading records from the Acme Platform via MCP tools. Data model notes: - `assignee` is the current owner of a work item, not necessarily the original tester or creator. - `internal_feedback_rating` is currently unused. QA ratings live inside each work item's history and cannot be bulk-aggregated through MCP tools. If a user asks for QA rating counts, explain the limitation rather than returning incorrect results. Best practices: - Use aggregate tools for counts/sums/averages instead of listing all rows. - Use `fetch_options` to resolve select filter values before filtering. PROMPT end ``` When `mcp_system_prompt` is left `nil` (default), the server falls back to a generic one-line instruction. Keep the prompt focused on semantic guidance and business rules — avoid pasting the full database schema, which the AI can already discover through `list_models` and the tool schemas. #### 4. Wire up Personal Access Tokens (PATs) The MCP controller's auth check looks up tokens against your host app's `ApiToken` model. The model needs a `token_type` enum (with at least a `pat` value) and an `expires_at` column. ##### Migration ```ruby class AddTokenTypeToApiTokens < ActiveRecord::Migration[8.1] def change add_column :api_tokens, :token_type, :integer, default: 0, null: false add_column :api_tokens, :expires_at, :datetime end end ``` ##### Model ```ruby class ApiToken < ApplicationRecord belongs_to :user enum :token_type, %i[access refresh pat] scope :live, -> { where(expires_at: [nil, Time.current..]) } validates :token, presence: true validates :token_type, presence: true before_validation :generate_token, on: :create private def generate_token self.token ||= SecureRandom.hex(32) end end ``` ##### Current attributes ```ruby # app/models/current.rb class Current < ActiveSupport::CurrentAttributes attribute :user, :api_token # ...plus your own attrs end ``` > The controller also sets `CmCurrent.user_permissions` (cached on > `cm_role.cache_key_with_version`) so `CmAdmin::PermissionHelper` and > `Pundit::Policy` work exactly as they do for web requests. #### 5. (Optional) Manage PATs from the cm-admin UI Register `ApiToken` like any other admin model. A ready-made concern is included with cm-admin — just `include` it and add the model to `config.included_models`: ```ruby # app/models/api_token.rb class ApiToken < ApplicationRecord include CmAdmin::ApiToken # ...rest of the model... end ``` ```ruby # config/initializers/cm_admin.rb config.included_models = [..., ApiToken] ``` Now there's an "API Tokens" sidebar entry where users can create PATs. --- ### Usage #### Client config The exact JSON shape varies by client; the universal fields are `serverUrl` (or `url`) and an `Authorization: Bearer ` header. ```json { "mcpServers": { "feature_test_app": { "serverUrl": "https://feature-test-app.commutatus.com/cm_admin/mcp", "headers": { "Authorization": "Bearer YOUR_PAT_HERE" } } } } ``` > The key (`feature_test_app` above) should match `config.x.project_settings.slug` > so the connection is obvious in the client UI. #### Generating a PAT Either through the cm-admin UI (**API Tokens → Add API Token**, set `token_type = pat`) or in a console: ```ruby user.api_tokens.create!(name: 'MCP Client', token_type: :pat, expires_at: 30.days.from_now) ``` #### Calling tools manually (debugging) ```bash PAT=... # your token HOST=http://localhost:3000 # Discovery curl -s -X POST "$HOST/cm_admin/mcp" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H "Authorization: Bearer $PAT" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' # List the first 5 users curl -s -X POST "$HOST/cm_admin/mcp" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H "Authorization: Bearer $PAT" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", "params":{"name":"user_list","arguments":{"per_page":5}}}' # Show a single record curl -s -X POST "$HOST/cm_admin/mcp" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H "Authorization: Bearer $PAT" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", "params":{"name":"user_show","arguments":{"id":1}}}' ``` #### Calling tools from the Rails console Each per-model tool is assigned a constant under its tool class, named after the model with `::` removed (`User` → `User`, `BlogManagement::Post` → `BlogManagementPost`). Tools are built at boot (and again on each code reload) when `mcp_enabled` is true, so the constants are available as soon as the console starts: ```ruby Current.user = User.first # policy scopes resolve against Current.user CmAdmin::Mcp::Tools::Models::ListTool::User.call(per_page: 5) CmAdmin::Mcp::Tools::Models::GetTool::User.call(id: "1") CmAdmin::Mcp::Tools::Models::ListTool::BlogManagementPost.call(page: 1) ``` `.call` returns an `MCP::Tool::Response`; use `.structured_content` for the result hash. --- ### Tool reference #### `list_models` ```json { "type": "object", "properties": {} } ``` Returns: ```json { "models": [{ "name": "User", "slug": "user", "actions": ["list", "show"] }] } ``` #### `fetch_options` | Argument | Type | Default | Description | | --------------- | ------ | ------- | ---------------------------------------------------------------------- | | `helper_method` | string | — | Required. The helper method name (e.g., `select_options_for_currency`) | | `model_name` | string | — | Optional. Model name (e.g., `User`, `DemoForm`) | | `context` | object | — | Optional. Record-like context for parent-dependent helpers | Returns: ```json { "helper_method": "select_options_for_currency", "options": [ { "label": "USD", "value": "usd" }, { "label": "EUR", "value": "eur" } ] } ``` #### `_list` | Argument | Type | Default | Description | | ---------------- | ------- | ------- | --------------------------------------------------------- | | `page` | integer | 1 | 1-indexed page number | | `per_page` | integer | 20 | Records per page (max 100) | | `sort_column` | string | — | Column to sort by (enum of columns configured via `sort`) | | `sort_direction` | string | `asc` | `asc` or `desc` | | `filters` | object | `{}` | Filter parameters based on cm_index filters | Records are filtered through `CmAdmin::PolicyIndexScope` — the same scope used by the cm-admin index page and exports. ##### Response structure ```json { "data": [...], "pagination": { "current_page": 1, "per_page": 20, "total_count": 100, "total_pages": 5, "has_more": true } } ``` - `data` — Array of records for the current page. Each record contains only the fields declared on `cm_index` (plus `id`); records are empty when no fields are declared. - `pagination` — Metadata for pagination: - `current_page` — Current page number - `per_page` — Records per page - `total_count` — Total number of records matching the query - `total_pages` — Total number of pages - `has_more` — Whether there are more pages available ##### Filter schema The `filters` parameter accepts key-value pairs based on the model's `cm_index` filters. For select filters using `helper_method`, the filter schema includes `x-helper-method` and `x-model-name` metadata. Use the `fetch_options` tool with these values to retrieve the current options dynamically. This keeps the schema small and ensures options are always fresh. #### `_show` | Argument | Type | Default | Description | | --------- | ------- | ------- | --------------------------------------------------------------- | | `id` | string | — | Required. Record ID or friendly ID (e.g., "123" or "CMAD-123"). | | `per_tab` | integer | 50 | Max records returned per tab association (max 200) | Returns the fields declared on `cm_show` (empty when none are declared), plus: ```json { "...": "...record fields...", "friendly_id": "CMAD-123", "nested_fields": { "": [ { "id": 1, "": "..." } ] }, "tabs": [ { "name": "Profile", "action": "", "type": null }, { "name": "Tokens", "action": "tokens", "type": "associated_index", "records": [ ... ] } ] } ``` - `friendly_id` — the friendly ID or formatted ID (only present if the model supports friendly_id) - `nested_fields` — every `nested_form_field` declared inside a `cm_section` under `cm_show`, with the fields you declared in the DSL. - `tabs` — every `tab` declared in `cm_show`. Each tab includes: - `name` — the tab's nav label - `action` — the action name (empty string for the default tab) - `type` — the layout type (`associated_index`, `associated_show`, or `null`) - `records` — present only when the tab maps to an association; policy-scoped --- ### Per-model control By default, models are **not** MCP-enabled. You must explicitly call `mcp_actions` inside the `cm_admin` block to enable MCP for a model: ```ruby class Article < ApplicationRecord cm_admin do mcp_actions # enables list + show # mcp_actions only: %i[list] # list only, no show # mcp_actions except: %i[show] # everything except show end end ``` --- ### Permissions The MCP server respects the same policy classes used by the web UI: | Action | Policy hook | | ----------------------- | --------------------------------------------------------------------------- | | `_list` | `CmAdmin::PolicyIndexScope#resolve` | | `_show` | `CmAdmin::PolicyIndexScope#resolve` + `CmAdmin::Policy#show?` | | Tab association records | `CmAdmin::PolicyIndexScope#resolve` | If a policy class is missing, the call falls back to `Model.all` (matches cm-admin's existing `authorize_with_policy` behavior). If `Pundit::NotAuthorizedError` is raised, MCP returns a structured `Forbidden` error to the client (not a 500). --- ### Error responses Errors are returned as MCP tool errors with a JSON-encoded body: | Scenario | HTTP status | Body | | ------------------------------ | ---------------- | --------------------------------------------------------------------------- | | Missing `Authorization` header | 401 | `{ "error": "Authorization token required" }` | | Unknown / expired PAT | 401 | `{ "error": "Invalid or expired token" }` | | `ActiveRecord::RecordNotFound` | 200 (tool error) | `{ "error": "Record not found", "message": "..." }` | | `Pundit::NotAuthorizedError` | 200 (tool error) | `{ "error": "Forbidden", "message": "..." }` | | Other `StandardError` | 200 (tool error) | `{ "error": "", "message": "..." }` and a `Rails.logger.error` entry | --- ### Observability The endpoint is a controller, so Scout APM records each MCP request as a web transaction. When `ScoutApm` is loaded, cm-admin renames the transaction after its [OpenTelemetry MCP span name](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/), prefixed with `MCP/`, so each tool gets its own endpoint in Scout: | Request | Scout transaction | | --------------------------------- | ---------------------------- | | `tools/call` for `user_list` | `MCP/tools/call user_list` | | `tools/list` | `MCP/tools/list` | | `initialize` | `MCP/initialize` | No configuration is needed. Requests rejected by the PAT check are not renamed and appear as `CmAdmin::McpController#handle`. --- ### Architecture (for the curious) ``` HTTP POST /cm_admin/mcp │ ▼ CmAdmin::McpController ← ActionController::API; before_action validates Bearer PAT, sets Current/CmCurrent │ ▼ MCP::Server + StreamableHTTPTransport ← built per request (stateless), per the ruby-sdk controller flow │ ▼ Tools: MCP::Tool subclasses ├── Global classes (list_models, fetch_options) └── Per-model subclasses built once at boot by Tools::Base.build_for (list / get / aggregate, dispatched by each tool's action_suffix), named :: (e.g. Tools::Models::ListTool::User) ``` Source files: - `app/controllers/cm_admin/mcp_controller.rb` — Bearer PAT auth + per-request server/transport - `lib/cm_admin/mcp/server.rb` — builds `MCP::Server` instance - `lib/cm_admin/mcp/registry.rb` — MCP-enabled model catalog - `lib/cm_admin/mcp/filters.rb` — MCP filter arguments → `CmAdmin::Models::Filter` queries - `lib/cm_admin/mcp/tools/base.rb` — `MCP::Tool` base class (build, naming, response + error handling) - `lib/cm_admin/mcp/tools/global/list_models_tool.rb` — discovery tool - `lib/cm_admin/mcp/tools/global/fetch_options_tool.rb` — select filter options fetcher - `lib/cm_admin/mcp/tools/models/` — per-model `list`, `get`, `aggregate` tool classes - `lib/cm_admin/mcp/authorization.rb` — Pundit `policy_scope` / `authorize!` - `lib/cm_admin/mcp/tool_schema.rb` — cm_admin fields/filters → tool input/output schemas - `lib/cm_admin/mcp/record_serializer.rb` — record → field-values hash (`fields`) and full show response (`show`) --- ## OTP & Authentication Cm-admin provides built-in OTP and password authentication flows that can be opted into via configuration. The gem ships with a Devise strategy, session controller concern, OTP model, delivery providers, and view templates — so host apps no longer need to maintain these files themselves. --- ### Overview The auth system supports three methods configured via `CmAdmin.config.auth_method`: | Method | Flow | | ----------- | ------------------------------------------------------------------------------- | | `:otp` | User enters email or phone → receives OTP → enters OTP to sign in | | `:password` | User enters email or phone → enters password to sign in | | `:custom` | Host app provides its own flow — the Devise strategy signs the user in directly | OTP delivery can go through email, phone (WhatsApp via Gupshup), or both. See [CmAdminConfiguration](./CmAdminConfiguration) for all config options. --- ### Setup #### 1. Enable Catalyst OTP in the initializer ```ruby # config/initializers/cm_admin.rb CmAdmin.configure do |config| config.auth_method = :otp # or :password, :custom config.use_catalyst_otp = true # registers the gem's Devise strategy + model # ...other config end ``` `use_catalyst_otp = true` registers the gem's `CmAdminAuthenticatable` Devise strategy and model module via `ActiveSupport::Reloader.to_prepare`. This runs after app initializers (where the flag is set) but before eager load (where the User model needs the module). Host apps that don't opt in are unaffected. #### 2. Generate the OTP requests migration ```bash rails g cm_admin:add_otp_requests rails db:migrate ``` This creates the `otp_requests` table with columns: `otp`, `email` (citext), `expired_at`, `status`, `verified_at`, `user_id` (optional foreign key), and timestamps. An index is added on `email`. #### 3. Update the User model Add the `OtpAuthenticatable` concern and `has_many :otp_requests` to your User model: ```ruby class User < ApplicationRecord include OtpAuthenticatable has_many :otp_requests, dependent: :destroy devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :cm_admin_authenticatable def can_access_admin_panel? true # or your own authorization logic end end ``` The `OtpAuthenticatable` concern provides: - `create_otp_request` — generates a 6-digit OTP, creates an `OtpRequest`, and delivers it - `verify_otp?(otp)` — validates the OTP against the latest active request - `verify_otp_with_email?(email, otp)` — validates OTP for email-based (no user) flows - `.create_otp_request_for_email(email)` — class method for creating OTPs without a user record #### 4. Configure the sessions controller The gem provides an `OtpSessions` concern that handles `validate_email`, `validate_credentials`, `validate_otp`, `validate_password`, `resend_otp`, and `sign_in_with_credentials`. Include it in your app's sessions controller: ```ruby # app/controllers/users/sessions_controller.rb module Users class SessionsController < Devise::SessionsController layout 'cm_session' helper CmAdmin::ViewHelpers include CmAdmin::OtpSessions # Add any custom actions or overrides here # e.g. reset_password_email, send_reset_password_email, update_password end end ``` The concern provides `after_successful_sign_in` and `after_sign_in_redirect_path` as extension points for host apps to customize post-login behavior. #### 5. Configure routes ```ruby # config/routes.rb Rails.application.routes.draw do devise_for :users, controllers: { sessions: 'users/sessions', passwords: 'users/passwords' } devise_scope :user do post 'validate_email', to: 'users/sessions#validate_email' get 'sign_in_with_credentials', to: 'users/sessions#sign_in_with_credentials' post 'validate_credentials', to: 'users/sessions#validate_credentials' get 'resend_otp', to: 'users/sessions#resend_otp' get 'reset_password_email', to: 'users/sessions#reset_password_email' post 'send_reset_password_email', to: 'users/sessions#send_reset_password_email' post 'update_password', to: 'users/sessions#update_password' end mount CmAdmin::Engine => '/cm_admin' end ``` --- ### Phone-Based OTP Delivery To deliver OTPs via phone (e.g. WhatsApp through Gupshup): #### 1. Configure delivery channel and provider ```ruby CmAdmin.configure do |config| config.auth_method = :otp config.use_catalyst_otp = true config.otp_delivery_channel = :phone_or_email # or :phone config.otp_delivery_provider = :gupshup end ``` When `otp_delivery_channel` is `:phone_or_email`, the login screen shows a **toggle** that lets the user switch between phone-based and email-based login: - The form shows a **mobile number field** (with the country-code selector used across cm-admin) by default. - A **"Use email instead"** link (styled like the "Forgot Password?" link) toggles to an email field; the link then becomes **"Use phone instead"** to switch back. The user can toggle between the two at any time before submitting. - OTP delivery follows the chosen identifier — a phone number sends the OTP via the configured phone provider only, and an email sends it via email only. This is handled automatically by the Devise strategy and `OtpSessions` concern based on whether the identifier contains `@`. #### 2. Add Gupshup credentials ```ruby # config/credentials.yml.enc or rails credentials:edit gupshup: api_key: "your_api_key" source_number: "your_sender_number" templates: otp: "your_gupshup_template_id" ``` #### 3. Ensure User model has `phone_number` The Gupshup provider calls `user.phone_number` — your User model must have a `phone_number` attribute. #### Custom delivery providers Pass any object that responds to `#deliver(user:, otp:, template:)`: ```ruby class MySmsProvider def deliver(user:, otp:, template:) # your SMS sending logic end end config.otp_delivery_provider = MySmsProvider.new ``` --- ### Custom User Model By default cm-admin uses the `User` model for authentication lookups, mentions, support tickets, and the history page. Override `user_class` with the model name or class: ```ruby config.user_class = 'AdminUser' # or config.user_class = AdminUser ``` `user_class` is lazily constantized, so it can be set before the model is loaded or after a Rails code reload. ### Custom User Lookup The default `user_lookup` lambda resolves users against `config.user_class` by email first, then by phone number (trailing digits match). Override it for custom logic: ```ruby config.user_lookup = lambda { |identifier| AdminUser.find_by(email: identifier.downcase) || AdminUser.find_by(phone_number: identifier.gsub(/\D/, '')) } ``` The lambda receives the raw `identifier` string from the login form (which accepts email or phone) and should return a User instance or `nil`. --- ### Migration from Host-App Files If your host app previously had its own copies of these files, you can remove them after enabling `use_catalyst_otp`: #### Files to remove from the host app 1. **`lib/devise/strategies/cm_admin_authenticatable.rb`** — The gem now provides this at `lib/devise/strategies/cm_admin_authenticatable.rb`. It's auto-registered when `use_catalyst_otp = true`. 2. **`lib/devise/models/cm_admin_authenticatable.rb`** — The gem provides this at `lib/devise/models/cm_admin_authenticatable.rb`. 3. **Devise module registration in `config/initializers/devise.rb`** — Remove the manual `Devise.add_module(:cm_admin_authenticatable, ...)` block. The gem's engine initializer handles this automatically when `use_catalyst_otp = true`. 4. **OTP methods from User model concern** — If your User model had inline `create_otp_request`, `verify_otp?`, etc. (e.g. in an `Auth::User` concern), replace them with `include OtpAuthenticatable`. 5. **Session controller actions** — If your `Users::SessionsController` had `validate_email`, `validate_credentials`, `validate_otp`, `resend_otp`, `sign_in_with_credentials` methods, replace them with `include CmAdmin::OtpSessions`. Keep only host-app-specific actions like `reset_password_email`, `send_reset_password_email`, `update_password`. #### What stays in the host app - `Users::SessionsController` — slim controller that includes `CmAdmin::OtpSessions` and adds password reset actions - `Users::PasswordsController` — Devise password reset controller (the gem doesn't provide this) - Routes — the devise scope routes must remain in the host app's `config/routes.rb` - `User` model — must include `OtpAuthenticatable` and `devise :cm_admin_authenticatable` --- ### How It Works #### Devise Strategy The `CmAdminAuthenticatable` strategy (`lib/devise/strategies/cm_admin_authenticatable.rb`) intercepts the `validate_email` action: 1. Extracts the `identifier` from params (supports both `identifier` and legacy `email` param names) 2. Calls `CmAdmin.config.user_lookup` to find the user 3. Checks `can_access_admin_panel?` on the user 4. On success: - For `:otp` — creates an OTP request (sends the code) and redirects to `sign_in_with_credentials` - For `:password` — redirects to `sign_in_with_credentials` (password entry page) - For `:custom` — calls `super(user)` to sign in directly 5. On failure — returns a generic error message #### OTP Model `OtpRequest` (`app/models/otp_request.rb`) manages OTP lifecycle: - **Statuses**: `created`, `verified`, `resent`, `time_out`, `cancelled` - **Expiry**: 15 minutes (scoped via `active` and `timed_out`) - **Rate limiting**: Max 5 OTP requests per 30 seconds per user or email - **Delivery**: `after_create` callback calls `CmAdmin.send_otp` or `CmAdmin.send_otp_email` #### Delivery Providers - **`CmAdmin::DeliveryProviders::Email`** — sends OTP via `CmAdmin.send_email` using the `cm_admin/mailers/otp/otp_email` partial - **`CmAdmin::DeliveryProviders::Gupshup`** — sends OTP via Gupshup WhatsApp API using configured templates The `CmAdmin.send_otp` method routes to the appropriate provider(s) based on `otp_delivery_channel` config. --- ### Views The gem provides these view templates: - `app/views/users/sessions/new.html.slim` — login form (identifier input) - `app/views/users/sessions/sign_in_with_credentials.html.slim` — OTP/password entry - `app/views/users/sessions/_otp_sign_in.html.slim` — OTP input partial - `app/views/users/sessions/_password_sign_in.html.slim` — password input partial - `app/views/cm_admin/mailers/otp/_otp_email.html.slim` — OTP email template Host apps can override any of these by creating files at the same paths in the host app's `app/views/` directory. --- ## Platform Settings ### Overview We can add platform settings with key-value management. #### Installation ```ruby rails g cm_admin:add_cm_platform_setting ``` #### Usage - Add or create a new constant type for platform settings category. - Add a helper method for platform settings category constant with name `select_options_for_setting_category`. NOTE: Adding this helper method is mandatory, otherwise platform settings page will fail. ```ruby def select_options_for_setting_category(_ = nil, _ = nil) # add the constant type for platform settings category according to your application. ::Constant.setting_category.map { |category| [category.name, category.id] } end ``` - We can create a translation by going to `/cm_admin/platform_settings`. ``` - setting - The name of the setting. Eg: Project Name - description - The description of the setting. Eg: The title used to identify your project across the system. - value - The value of the setting. Eg: Commutatus - category - The category of the setting. Eg: Emails ``` #### Project settings vs platform settings These are two separate things and never override each other: | | Project settings | Platform settings | | ------------ | -------------------------------------------------------- | --------------------------------------------------- | | Set by | Developers, in `config/application.rb` | Admins, on the Platform Settings page | | Stored in | `config.x.project_settings` | The `cm_platform_settings` table, cached | | Read with | `Rails.configuration.x.project_settings.name` | `CmAdmin::PlatformSettings["slug"]` | | Examples | Project name, slug, logo URL, favicon URL, default email | Login layout, login image, Catalyst AI model, queue | | Changes need | A deploy | Nothing; they apply immediately | A platform setting with the same name as a project setting (for example an old **Project Name** row) has no effect on `config.x.project_settings`. #### Reading settings Read every platform setting through `CmAdmin::PlatformSettings`, from anywhere in the app (models, views, jobs, mailers): ```ruby CmAdmin::PlatformSettings["ct-bot-queue"] # => "default"; raises if the setting doesn't exist CmAdmin::PlatformSettings.fetch("login-layout", "background_image") # default used when the setting is missing or blank CmAdmin::PlatformSettings.fetch("ct-bot-model") { "openai/gpt-5.6-sol" } ``` - `[]` raises `CmAdmin::PlatformSettings::NotFound` (a `KeyError`) when no setting has that slug, so a typo or a missing seed fails loudly instead of returning `nil`. It returns `nil` only for a setting that exists with no value. - `fetch` works like `Hash#fetch`: pass a default or a block when the code has a sensible fallback. Without one, it raises `NotFound` too. - Settings are looked up by **slug** (the parameterised name: `Login Layout` → `login-layout`). - A setting with an uploaded file returns its Active Storage redirect path (`/rails/active_storage/blobs/redirect/...`), which never expires and can be used directly in `image_tag` or a CSS `url()`. --- ## Role and Permission Management 🎭 ### Overview Role and permission management is facilitated through CmAdmin, which dynamically creates Pundit policies. This tool allows us to manage permissions via an intuitive interface. ### Features - **Create Role:** Easily create any role needed for the application. - **Manage Permissions:** View and modify all possible actions for each role, enabling or disabling permissions as necessary. ### Usage #### Adding Role and Permission Table Run the following command to add the role and permission table: ```bash rails g cm_admin:install_role ``` This Rake task generates a default migration. **Note:** Ensure that you have the `paper_trail` gem installed before running the command. #### Creating Role Column on User Table To create a role column in the user table, execute: ```bash rails g migration AddCmRoleToUser cm_role:references ``` #### Adding Unique Index for Bulk Permission Updates For improved performance with bulk permission updates, add a unique composite index: ```bash rails g cm_admin:add_unique_index_to_permissions ``` This creates a migration that adds a unique index on `(ar_model_name, action_name, cm_role_id)` to enable efficient `upsert_all` operations for permission management. **Note:** The column name must be `cm_role_id`, or the policy will fail. #### Assigning Roles to Users Currently, each user can be assigned only one role. To set the current request parameters: 1. In `app/models/current.rb`, add `request_params` as an attribute. 2. In `app/controllers/concerns/authentication.rb`, set the request parameters to help CmAdmin identify the action in the Pundit policy. ```ruby module Authentication extend ActiveSupport::Concern included do before_action :check_current_user before_action :set_params end def set_params Current.request_params = params if params end # Add other methods here end ``` 3. Add `belongs_to :cm_role, optional: true` in the `User` model. 4. Include `CmRole` in the `config.included_models` section of `config/initializers/cm_admin.rb`. 5. Assign `cm_role_id` to `1` for any user in the `User` Model, and use that user to log in. ### Setting up scopes By default, `Full Access` scopes is added to each permission item. To add additional scopes, use the following syntax: ```ruby ... cm_admin do actions only: [] icon_name "fa fa-user" policy_scopes [{ scope_name: 'test_supplier_filter', display_name: 'By Test Supplier' }] cm_index do page_title 'User' end end ``` ### Using Permission Scopes in Code The `cm_permission_scoped_records` helper method allows you to filter records based on the current user's permission scope for a given model. This is useful when you need to apply the same scoping logic outside of the standard CmAdmin interfaces (e.g., in background jobs, custom controllers, or models). #### Including the Helper The helper is available in `CmAdmin::PermissionHelper`. Include it in your class or module: ```ruby module CmAdmin module CustomHelper include CmAdmin::PermissionHelper def select_options_for_users(_ = nil, _ = nil) cm_permission_scoped_records(::User).map { |user| [user.cm_display_name, user.id] } end end end ``` #### Method Signature ```ruby cm_permission_scoped_records(model_class) ``` - **model_class**: The ActiveRecord model class to scope (e.g., `User`, `Order`, `Item`) - **Returns**: An `ActiveRecord::Relation` scoped according to the user's permissions #### How It Works 1. Looks up the current user's role and permissions 2. Finds the permission for the given model with action `'index'` 3. If the permission has a scope name (other than `'all'`), calls that scope on the model 4. Falls back to `model_class.all` if no scope is defined or scope does not exist #### Requirements - User must have a `cm_role` association - Role must have `cm_permissions` with the appropriate model and `'index'` action - The model must define the scope method specified in the permission ### Overriding Policies By default, roles and policies are enabled for all models in the application. To override a policy, use the following syntax: ```ruby ... cm_admin do actions only: [] icon_name "fa fa-user" override_policy true cm_index do page_title 'User' end end ``` Then, create a policy file for the respective model, e.g., `app/policies/cm_admin/user_policy.rb`: ```ruby class CmAdmin::UserPolicy < ApplicationPolicy def index? true end # Add other actions here end ``` This structure helps ensure that your application's role and permission management is both flexible and secure. ### Permission based fields We can apply permission logic to display a field on the interface. You can do this with the following syntax. ```ruby ... tab :details, '' do row do cm_show_section 'Details' do field :status, field_type: :badge, badge_class: Item::STATUS_TAG_COLOR, display_if: -> (record) { scoped_model = CmAdmin::ItemPolicyArchiveScope.new(Current.user, ::Item).resolve return scoped_model.find_by(id: record.id).present? } end end end ``` ### Adding Default Redirect Path for a Role - We can add default redirect path for a role via cm admin. - need to add this on `routes.rb`. ```ruby ... root 'cm_admin/static#index' ``` - Create a migration to add a column for default redirect path. (Will be automatically added in new apps) ```ruby ... add_column :cm_roles, :default_redirect_path, :string, default: "#{CmAdmin::Engine.mount_path}/users" ``` ### Permission-Based Access Control Always use permission-based methods instead of role checks when controlling access to features, fields, or actions. #### Add these methods to your User model First, add the following permission helper methods to your User model: ```ruby def permission_with_scope?(model_name:, action_name:, scope_name:) cm_role.cm_permissions.where(ar_model_name: model_name, action_name:, scope_name:).exists? end def has_permission?(model_name, action_name) return false unless cm_role.present? cm_role.cm_permissions.exists?(ar_model_name: model_name, action_name: action_name) end ``` #### Use `permission_with_scope?` for scoped permissions When checking permissions with a specific scope (e.g., 'all', 'own', 'team'): ```ruby unless Current.user.permission_with_scope?(model_name: 'ReimbursementRequest', action_name: 'revert_acknowledged', scope_name: 'all') # Handle unauthorized access end ``` #### Use `has_permission?` for general permissions When checking permissions without a scope: ```ruby column :note_type, header: 'Note Type', field_type: :enum, display_if: lambda { |_| ::Current.user.has_permission?('Customer', 'customer_restricted_fields') } ``` #### Never use role checks Avoid using role-based checks like: - `Current.user&.role?('admin')` - `Current.user.cm_role.name == 'admin'` Instead, always use the permission methods defined above which provide fine-grained access control based on the user's role permissions. --- ## Saved Views ### Overview Saved Views let each user save the current state of an index page under a name and come back to it in one click. A view stores: - Search and filters (standard and [advanced](./AdvancedFilters.md)) - Sort - Visible and hidden columns, including column order (Manage Columns) - Records per page - View type (`table`, `card`, `kanban`) Views are private to their owner (`Current.user`) and are scoped per model. Associated (tab) tables keep their own separate set of views. Saved Views replace the old **Index Preference** feature (`cm_index_preferences`). ### Setup #### 1. Run the generator ```bash rails generate cm_admin:ct_page_view rails db:migrate ``` The migration: - Enables the PostgreSQL `citext` extension, so view names are case-insensitive. - **Drops the legacy `cm_index_preferences` table** if it exists. Its data is not migrated. - Creates `ct_page_views`: | Column | Type | Notes | | -------------------------- | ----------------------- | ---------------------------------------------------- | | `owner` | polymorphic reference | Required. Set to `Current.user` | | `name` | `citext` | Required | | `ar_model_name` | `string` | Model whose index page the view belongs to | | `associated_ar_model_name` | `string`, default `''` | Parent model when the view is on an associated tab | | `visible_columns` | `string[]` | Ordered | | `hidden_columns` | `string[]` | | | `is_default` | `boolean` | At most one per owner / model (partial unique index) | | `view_type` | `integer` enum | `table: 0`, `card: 1`, `kanban: 2` | | `records_per_page` | `integer`, default `20` | | | `filter_params` | `jsonb` | Decoded filters hash | | `sort_params` | `json` | | See [Generators](../Generators.md#17-ct-page-view-generator). #### 2. Requirements - `DEFAULT_PER_PAGE` must be defined (it is generated in `config/initializers/constants.rb`). `PER_PAGE_OPTIONS` is optional and defaults to `[10, 20, 50, 100]`. - `Current.user` must be set for admin requests. - PaperTrail must be set up. `CtPageView` uses `has_paper_trail`. There is no DSL to add: Saved Views appear on every index page and associated table. ### Usage | Action | How | | --------------------- | --------------------------------------------------------------------------------------------------------- | | Save a view | Set filters, sort, and columns, then **Save View**, enter a name, and **Save** | | Apply a view | **Saved Views**, then click the view | | Update a view | Apply it, then change filters, sort, columns, or page size. Changes are **auto-saved** to the active view | | Clear the active view | Click **×** on the view button. This returns to the unfiltered page | | Set as default | Use the **⋮** menu, then **Set as Default**. The view opens automatically when you visit the page | | Others | Use the **⋮** menu: **Copy Link**, **Edit** (rename), **Duplicate**, **Delete** | #### Default view behaviour When a user opens an index page with **no** `filters`, `current_ct_page_view_id`, `clear_filters`, or `clear_ct_page_view` params, they are redirected to their default view. Links that already carry any of these params, such as shared filtered links, open as-is. #### Shareable links A view applies itself through query params: `current_ct_page_view_id`, `filters`, `sort`, and `view_type`. **Copy Link** copies these params. Because views belong to their owner, other users only get the filters, sort, and view type from the link, not your saved columns. ### Routes These routes are added automatically for every cm-admin model, with no setup needed: | Verb | Path | Action | | ------ | --------------------------------------- | ------------------------ | | GET | `//ct_page_views` | `list_ct_page_view` | | POST | `//ct_page_views` | `create_ct_page_view` | | PUT | `//ct_page_views/:id` | `update_ct_page_view` | | DELETE | `//ct_page_views/:id` | `destroy_ct_page_view` | | POST | `//ct_page_views/:id/duplicate` | `duplicate_ct_page_view` | Route helpers follow the pattern `_ct_page_view__path`, for example `create_ct_page_view_user_path`. The list route's helper is `ct_page_view__path`. ### Working with `CtPageView` in Ruby ```ruby view = CtPageView.create!( owner: user, name: 'Active adults', ar_model_name: 'User', filter_params: { advanced: [{ filter_type: 'single_select', filter_name: 'status', condition: 'is', value: 'active' }] }, sort_params: { created_at: { dir: 'desc', type: nil } }, visible_columns: %w[name email status], records_per_page: 50, is_default: true ) view.apply_params # => { current_ct_page_view_id: 1, filters: "eJy...", sort: { "created_at" => { ... } } } view.apply_url # => index path with the params above ``` Setting `is_default: true` automatically unsets the previous default for the same owner, model, and associated model. ### Upgrading from Index Preference See the [v8.2.0 upgrade guide](../5.%20Upgrade%20Guide/v8.2.0.md#index-preferences-replaced-by-saved-page-views). --- ## Hide or Show Section and Fields 🦹 This module provides methods for hiding form sections and fields with HTML attributes. ### Method 1: Using HTML Attributes and JavaScript for Form Section #### Adding Custom HTML Attributes Define the html_attrs attribute in a section / field to add custom HTML attributes. ```ruby cm_section 'Private Van', html_attrs: { "data-behaviour": "private_van-section"} do form_field :transport_vehicle_number, input_type: :string, label: 'Vehicle Number' form_field :transport_vehicle_in_charge, input_type: :string, label: 'Vehicle In Charge' form_field :transport_vehicle_in_charge_phone, input_type: :integer, label: 'Vehicle In Charge Phone' end ``` The below JavaScript code manages the visibility of the "Private Van" section based on a condition. **Location**: Define this JavaScript function in your application's JavaScript file. ```javascript $(document).on("turbo:load", function() { $(document).on('change', 'select[data-behaviour="transport_type-select"]', handleTransportTypeChange); }); function handleTransportTypeChange(event) { // Get the section element by its data attribute var section = $('div[data-behaviour="private_van-section"]'); // Get the selected value var selectedValue = $(event.target).val(); // Toggle the visibility based on the condition if (selectedValue !== "private_van") { section.addClass('hidden'); } else { section.removeClass('hidden'); } } ``` ### Method 2: Using `display_if` Attribute for Form Section #### Adding `display_if` Attribute for Form Section Define the `display_if` attribute in a section to conditionally display the section based on a condition. ```ruby cm_section 'Private Van', display_if:->(trip){trip.transport_type== "private_van" } do form_field :transport_vehicle_number, input_type: :string, label: 'Vehicle Number' form_field :transport_vehicle_in_charge, input_type: :string, label: 'Vehicle In Charge' form_field :transport_vehicle_in_charge_phone, input_type: :integer, label: 'Vehicle In Charge Phone' end ``` #### Adding `display_if` Attribute for Form Field Define the `display_if` attribute in a field to conditionally display the field based on a condition. ```ruby form_field :transport_vehicle_number, input_type: :single_select,collection: :mode_of_transport, display_if:->(trip){ trip.transport_type=="private_van" } ``` You can also use the `display_if` attribute in any field to conditionally display the field based on a condition. 💡Note: The `display_if` attribute is used to conditionally display the section based on the value of `trip.transport_type`. It doesn't updated in real time like the JavaScript method. --- ## Showing Hiding Fields with Stimulus ### Overview We can show and hide fields dynamically with stimulus html attributes. It works on simple fields and nested form fields. ### Usage We need to pass html attributes for using this. #### Hiding a Field. 1. We need to specify a `data-action`. - `input` is event name. - `fields` is controller name. - `hide` is action name. 2. We need to provide the `data-cm-hidden-id`. These are ID's of target elements. This ID is added automatically from `from_object`. 3. we can pass `data-cm-toggle-values` a hash for hiding fields on particular values or we can pass `data-cm-toggle-value` a string if all fields need to hidden on a single value. 4. On Target element we need to pass `data-fields-target`, this is stimulus feature and it's value can be either `cmHidden` and `cmVisible`. ```ruby form_field :title, input_type: :string, html_attrs: { 'data-action': 'input->fields#hide', 'data-cm-hidden-id': 'is_featured tag_ids', 'data-cm-toggle-values': '{ "is_featured": "hide", "tag_ids": "hides" }' } form_field :is_featured, input_type: :switch, label: 'My Custom Switch Label', html_attrs: { 'data-fields-target': 'cmHidden' } ``` #### Showing a Field. 1. It's similar to hiding field. There are some changes: - action will be changed to `show`. - `data-cm-hidden-id` will be changed to `data-cm-visible-id`. - `data-fields-target` value will be changed to `cmVisible`. ```ruby form_field :user_id, input_type: :single_select, helper_method: :select_options_for_users, html_attrs: { 'data-action': 'change->fields#show', 'data-cm-visible-id': 'is_featured tag_ids', 'data-cm-toggle-value': ::User.first&.id.to_s } form_field :tag_ids, input_type: :multi_select, helper_method: :select_options_for_tag, html_attrs: { 'data-fields-target': 'cmVisible' } ``` #### Hiding a Nested Form Field. `nested_form_field` also accepts `html_attrs`. The attributes are applied to the nested section wrapper, so the whole nested section can be toggled. The wrapper automatically gets `data-cm-id` set to the association name and `data-toggle-cm-class` set to `nested-field-wrapper`, so only `data-fields-target` needs to be passed. ```ruby form_field :has_no_previous_addresses, label: 'I have no previous addresses', input_type: :switch, html_attrs: { 'data-action': 'input->fields#hide', 'data-cm-hidden-id': 'addresses' } nested_form_field :addresses, html_attrs: { 'data-fields-target': 'cmHidden' } do form_field :address_type, label: 'Type', input_type: :single_select, helper_method: :residential_address_type_option_for_select form_field :street_address end ``` --- ## CmAdmin Support Ticket Management 🎫 ### Overview CmSupportTicket is a feature that allows users to create and manage support tickets within the application, which will get synced with the bridge system. ### Usage As mentioned above, this feature two applications running in parallel. App1 - Eg DRE - Running on port 3000 App2 - Eg Bridge - Running on port 3001 Bridge development setup: - Create a project for DRE in the bridge application, where tasks can be created. - Create a CmAdmin installation and associate the project. - Once created, copy the project key and add it to the DRE credentials file. It will look like this: ```yaml cm_admin: project_access_key: PROJECT_KEY ``` #### Adding migration Run the following command to add necessary tables and columns ```bash rails g cm_admin:add_cm_support_ticket ``` This Rake task generates three migration files - One to create the cm_support_ticket table - Second to create the cm_support_ticket_user table for assigning users - Third to add created_by_name and email to cm_comment #### Initializer Add the CmSupportTicket to cm_admin.rb file ```ruby CmAdmin.configure do |config| config.included_models = [ # ... other models, CmSupportTicket ] config.sidebar = [ # ... other items, { path: :cm_index_cm_support_ticket_path } ] end ``` #### Credentials For development set the project key in credentials file along with host_url ```yaml cm_admin: project_access_key: your_project_key_here ``` For Staging and Production: - Generate the project key in the bridge application and add it to the environment variable `BUNDLE_CM___GEMS__COMMUTATUS__COM` Note: This environment variable is shared between both applications for bridge integration. The same production project key from Bridge will be used across both applications. Remember to update the corresponding staging key in the Staging Bridge environment when deploying to staging. Note: While running sidekiq, Run sidekiq on one application only, not on both. If we run in parallel, Jobs can run on other application and cause confusion. We need to fix this. --- ## Trackable ### Overview The `Trackable` concern automatically records which user created or last updated a record by storing references to the `User` model via `created_by_id` and `updated_by_id` columns. It relies on `Current.user` being set in the request lifecycle. --- ### Quick Start #### 1. Include the Concern in Your Model ```ruby class Order < ApplicationRecord include Trackable end ``` #### 2. Run the Generator The generator scans all models that include `Trackable` and creates a single migration for any that are missing the required columns: ```bash rails g cm_admin:add_trackable_columns rails db:migrate ``` The generator will output which models and columns are being added, or confirm that everything is already in place: ``` Found 2 model(s) missing trackable columns: - Order: missing created_by_id, updated_by_id - Product: missing updated_by_id ✅ Migration created: db/migrate/20240101120000_add_trackable_columns_to_models.rb ⚙️ Run `rails db:migrate` ``` --- ### What It Does Once included and migrated, the concern: - Sets `created_by_id` to `Current.user.id` after a record is created - Sets `updated_by_id` to `Current.user.id` after every update - Skips the assignment if `Current.user` is not present (e.g., background jobs, seeds) It also adds delegates so you can access user details directly on the record: ```ruby order.created_by_full_name # => "Jane Doe" order.created_by_email # => "jane@example.com" order.updated_by_full_name # => "John Smith" order.updated_by_email # => "john@example.com" ``` --- ### Requirements - **`Current.user`** must be set in the request lifecycle (e.g., via `ApplicationController`) - The model's table must have `created_by_id` and `updated_by_id` columns (added by the generator) - A `User` model must exist in the application --- ### Displaying Tracking Info in cm-admin You can surface `created_by` and `updated_by` on index and show pages like any other column: ```ruby cm_admin do cm_index do column :created_by_full_name, header: "Created By" column :updated_by_full_name, header: "Last Updated By" end cm_show do cm_section do cm_row do field :created_by_full_name, label: "Created By" field :updated_by_full_name, label: "Last Updated By" end end end end ``` --- ## Translation ### Overview We can convert certain keys to the language the application needs. #### Installation ```ruby rails generate 'i18n:active_record:install' ``` #### Usage - Create a migration to add a column for locale for the user. ```ruby class AddLocaleToUser < ActiveRecord::Migration[7.2] def change add_column :users, :locale, :string, default: 'en' end end ``` - Add the following line to the `config/initializers/i18n_active_record.rb` file. ```ruby require 'i18n/backend/active_record' I18n.backend = I18n::Backend::Chain.new( I18n::Backend::ActiveRecord.new, # DB-backed translations (editable at runtime) I18n.backend # fallback to the default Simple backend ) I18n::Backend::ActiveRecord.configure do |config| config.cache_translations = true # defaults to false # config.cleanup_with_destroy = true # defaults to false config.scope = 'cm_admin' # defaults to nil, won't be used end ``` - We can create a translation by going to `/cm_admin/translations`. ```ruby - key - The text that needs to be converted. Eg: Edit - value - The value that needs to be applied. Eg: يحرر - locale - The language in which the text needs to be converted. Eg: ar ``` - Add this method to the CM admin application controller ```ruby around_action :switch_locale def switch_locale(&action) locale = params[:locale] || Current.user.locale || I18n.default_locale I18n.with_locale(locale, &action) end ``` #### Important Notes - Success messages are not converted yet. --- ## CmAdmin Interactive Dashboard System ### Overview The CmAdmin Interactive Dashboard System allows you to create fully interactive, namespaced dashboards with dynamic widgets. These dashboards can be global or associated with specific models and can be embedded in model views or other sections. This guide will walk you through setting up and using the dashboard system with the new dashboard helper functionality and permission scoping. ### Setup #### Database Setup First, you need to run the migration to create the necessary tables: ```bash rails db:migrate ``` ### Dashboard Helper System #### Creating Dashboard Helpers Create custom dashboard helper methods in `/app/helpers/cm_admin/dashboard_helper.rb`: ```ruby module CmAdmin module DashboardHelper # Dashboard helper methods receive (scoped_ar_object, scoped_filters) # scoped_ar_object is already permission-scoped based on user permissions def total_users_metric(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object # Already permission-scoped # Apply dashboard filters if scoped_filters[:single_select].present? scoped_filters[:single_select].each do |key, value| next if value.blank? base_query = base_query.where(key => value) end end if scoped_filters[:search].present? search_term = scoped_filters[:search] base_query = base_query.where("email ILIKE ? OR first_name ILIKE ?", "%#{search_term}%", "%#{search_term}%") end base_query.count end def users_by_role_chart_data(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object # Apply filters similar to above # ... filter logic ... base_query.joins(:cm_role).group('cm_roles.name').count end end end ``` #### Permission Scoping Dashboard helpers automatically receive permission-scoped data: 1. **Dashboard-specific permissions**: Uses `scope_name` from permissions with matching `action_name` and `ar_model_name` 2. **Policy scope fallback**: Falls back to `CmAdmin::{Model}PolicyIndexScope` if no dashboard-specific scope 3. **Filter integration**: Dashboard filters are automatically scoped and passed to helper methods ### Creating Dashboards #### Global Dashboards Global dashboards are not associated with any specific model: ```ruby dashboard "Admin Dashboard" do box "User Statistics" do metric value: :total_users_metric, title: "Total Users" chart "Users by Role", type: :pie, data: :users_by_role_chart_data table "Recent Users", data: :recent_users_table_data end box "System Statistics" do metric value: -> { Rails.cache.stats[:hits] }, title: "Cache Hits" chart "Daily Signups", type: :line, data: :daily_signups_chart_data end end ``` #### Model-Associated Dashboards Associate dashboards with specific models: ```ruby # app/models/concerns/cm_admin/student.rb module CmAdmin module Student extend ActiveSupport::Concern included do cm_admin do actions only: [] icon_name 'fa fa-graduation-cap' cm_dashboard :dashboard_student_type, display_name: 'Dashboard', page_title: 'Dashboard', page_description: 'Student Dashboard' do # Permissions / access control to be wired into your system: # - Full Access # - User Office Only # - Assignee Only # Filters - These will be automatically passed to dashboard helpers filter :office_id, :multi_select, helper_method: :select_options_for_office, placeholder: 'Office', display_name: 'Office', active_by_default: true filter :assigned_to_id, :multi_select, helper_method: :select_options_for_lead_assignees, placeholder: 'Assignee', display_name: 'Assignee', active_by_default: true filter :application_coordinator_id, :multi_select, helper_method: :select_options_for_application_coordinators, placeholder: 'Application Coordinator', display_name: 'Application Coordinator', active_by_default: true, filter_with: :with_application_coordinator_ids # Row 1 – 3 metric cards (Total Leads | Total Prospects | Total Applications) row do cm_section '' do metric_card :total_leads_metric, title: 'Total Leads' end cm_section '' do metric_card :total_prospects_metric, title: 'Total Prospects' end cm_section '' do metric_card :total_applications_metric, title: 'Total Applications' end end # Charts – Layout: 2 columns, Pie Chart for all # Row 2 – Leads by Status | Prospects by Status row do cm_section '' do chart :leads_by_status_chart_data, title: 'Leads by Status', chart_type: :pie_chart end end row do cm_section '' do chart :prospects_by_status_chart_data, title: 'Prospects by Status', chart_type: :pie_chart end end # Row 3 – Applications by Status (single column) row do cm_section '' do chart :applications_by_status_chart_data, title: 'Applications by Status', chart_type: :pie_chart end end end cm_index do page_title 'Students' # These same filters will be used for dashboard filtering filter %i[first_name last_name email], :search, placeholder: 'Search' filter :office_id, :multi_select, helper_method: :select_options_for_office filter :assigned_to_id, :multi_select, helper_method: :select_options_for_lead_assignees sort_column :created_at sort_direction :desc column :full_name column :email column :office_name, header: 'Office' column :status column :created_at, field_type: :date, format: '%d %b, %Y' end end end end end ``` #### Corresponding Dashboard Helper Methods Create the dashboard helper methods in `/app/helpers/cm_admin/dashboard_helper.rb`: ```ruby module CmAdmin module DashboardHelper # Metric card methods - receive permission-scoped data and filters def total_leads_metric(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object.where(status: 'lead') apply_dashboard_filters(base_query, scoped_filters).count end def total_prospects_metric(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object.where(status: 'prospect') apply_dashboard_filters(base_query, scoped_filters).count end def total_applications_metric(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object.where(status: 'application') apply_dashboard_filters(base_query, scoped_filters).count end # Chart data methods def leads_by_status_chart_data(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object.where(status: 'lead') filtered_query = apply_dashboard_filters(base_query, scoped_filters) filtered_query.group(:sub_status).count end def prospects_by_status_chart_data(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object.where(status: 'prospect') filtered_query = apply_dashboard_filters(base_query, scoped_filters) filtered_query.group(:sub_status).count end def applications_by_status_chart_data(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object.where(status: 'application') filtered_query = apply_dashboard_filters(base_query, scoped_filters) filtered_query.group(:application_status).count end private # Helper method to apply dashboard filters consistently def apply_dashboard_filters(base_query, scoped_filters) return base_query if scoped_filters.blank? # Apply multi-select filters if scoped_filters[:multi_select].present? scoped_filters[:multi_select].each do |key, values| next if values.blank? case key.to_s when 'office_id' base_query = base_query.where(office_id: values) when 'assigned_to_id' base_query = base_query.where(assigned_to_id: values) when 'application_coordinator_id' base_query = base_query.where(application_coordinator_id: values) end end end # Apply search filters if scoped_filters[:search].present? search_term = scoped_filters[:search] base_query = base_query.where( "first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ?", "%#{search_term}%", "%#{search_term}%", "%#{search_term}%" ) end # Apply date filters if scoped_filters[:date].present? scoped_filters[:date].each do |key, date_range| next if date_range.blank? base_query = base_query.where(key => date_range) end end base_query end end end ``` ### Widget Types #### Metric Cards Display single numeric values: ```ruby metric :total_users_metric, title: "Total Users", description: "All registered users" ``` #### Charts Display data visualizations: ```ruby # Pie Chart chart :users_by_role_chart_data, type: :pie, title: "Users by Role" # Line Chart chart :daily_signups_chart_data, type: :line, title: "Daily Signups" # Bar Chart chart :monthly_stats_chart_data, type: :bar, title: "Monthly Stats" ``` #### Tables Display tabular data: ```ruby table "Recent Users", data: :recent_users_table_data ``` ### Advanced Features #### Filter Integration Dashboard filters are automatically passed to helper methods: ```ruby def filtered_users_metric(scoped_ar_object, scoped_filters = {}) base_query = scoped_ar_object # Single select filters if scoped_filters[:single_select]&.dig(:cm_role_id).present? base_query = base_query.where(cm_role_id: scoped_filters[:single_select][:cm_role_id]) end # Date filters if scoped_filters[:date]&.dig(:created_at).present? date_range = scoped_filters[:date][:created_at] base_query = base_query.where(created_at: date_range) end # Search filters if scoped_filters[:search].present? search_term = scoped_filters[:search] base_query = base_query.where("email ILIKE ?", "%#{search_term}%") end base_query.count end ``` #### Permission-Based Scoping Dashboard helpers automatically respect user permissions: ```ruby # This helper will only see data the user has permission to access def department_users_metric(scoped_ar_object, scoped_filters = {}) # scoped_ar_object is already filtered based on: # 1. Dashboard-specific permission scope (if configured) # 2. Model policy scope (fallback) scoped_ar_object.where(department: 'Engineering').count end ``` #### Error Handling Dashboard helpers include built-in error handling: ```ruby def safe_metric_calculation(scoped_ar_object, scoped_filters = {}) begin # Your calculation logic scoped_ar_object.complex_calculation rescue StandardError => e Rails.logger.error "Dashboard metric error: #{e.message}" 0 # Return safe default value end end ``` ### Best Practices 1. **Use descriptive method names**: `total_active_users_metric` instead of `user_count` 2. **Handle empty data gracefully**: Return appropriate defaults for empty datasets 3. **Apply filters consistently**: Use the same filter logic across related widgets 4. **Optimize queries**: Use includes, joins, and select to optimize database queries 5. **Cache expensive calculations**: Use Rails caching for complex metrics 6. **Test with different permission levels**: Ensure widgets work for all user types ### Security Considerations - Dashboard helpers automatically receive permission-scoped data - Filters are validated against dashboard configuration - All database queries respect user access controls - Error messages don't expose sensitive information This updated dashboard system provides a secure, flexible, and powerful way to create interactive dashboards with proper permission handling and filter integration. --- ## Status Tags ### Overview CmAdmin provides a set of Catalyst Tag classes used to display enum or state values with a colored badge. These are rendered via the `:badge` field type and are controlled using the `badge_class` option. ```ruby column :status, field_type: :badge, badge_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow', rejected: 'cm-badge-red' } ``` :::note The `:tag` field type and `tag_class` option were renamed to `:badge` and `badge_class` in v8.0.0. Using `:tag` raises an error at boot. See the [v8.0.0 upgrade guide](../5.%20Upgrade%20Guide/v8.0.0.md). ::: All tag classes use a `cm-badge-` prefix (Catalyst Tag) to avoid conflicts with generic CSS utility classes. ### Available Tag Classes | Class | Preview | Use Case Example | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | `cm-badge-blue` | Sample | Active, In Progress, Enabled | | `cm-badge-yellow` | Sample | Pending, Awaiting, Draft | | `cm-badge-orange` | Sample | On Hold, Needs Review, Processing | | `cm-badge-purple` | Sample | Scheduled, Queued, Resolved | | `cm-badge-cyan` | Sample | Info, Beta, Staging | | `cm-badge-green` | Sample | Completed, Approved, Verified | | `cm-badge-gray` | Sample | Inactive, Archived, Unknown | | `cm-badge-red` | Sample | Rejected, Failed, Blocked, Deleted | ### Removed Aliases The following old class names are **no longer supported**: they have no styles, and `field` raises an error at boot if `badge_class` uses them. Replace them with the Catalyst equivalents: | Removed | Use Instead | | ----------------------- | ----------------- | | `active` / `active-one` | `cm-badge-blue` | | `active-two` | `cm-badge-yellow` | | `active-three` | `cm-badge-orange` | | `active-four` | `cm-badge-purple` | | `active-five` | `cm-badge-cyan` | | `completed` | `cm-badge-green` | | `neutral` | `cm-badge-gray` | | `danger` | `cm-badge-red` | :::warning Bootstrap badge color classes (`bg-success`, `bg-warning`, `bg-danger`, `bg-secondary`) are **not supported** as badge classes. Always use `cm-badge-*` Catalyst Tags. ::: ### Best Practices 1. **Use `cm-badge-green`** for positive terminal states (approved, verified, done) 2. **Use `cm-badge-red`** for negative terminal states (rejected, failed, blocked) 3. **Use `cm-badge-gray`** for inactive or non-significant states (archived, unknown) 4. **Use `cm-badge-blue`** for currently live or enabled items 5. **Use `cm-badge-yellow`** for items awaiting action (pending, draft) 6. **Use `cm-badge-orange` through `cm-badge-cyan`** to differentiate multiple intermediate states when two or more non-red/green statuses exist 7. **Never use Bootstrap badge color classes** — they are not equivalent and will produce inconsistent styling --- ## Catalyst Config Catalyst Config is the mechanism cm-admin uses to bundle and sync deploy configuration files (like `.hatchbox/`) from the gem into your application. It works like Rails migrations — but for config files instead of database schema. ### How It Works ``` deploy-config/ ← gem source (ships with cm-admin) .hatchbox/ ← synced to app_root/.hatchbox/ (any future config) ← synced to app_root/ ``` 1. The gem stores all deploy config files in `deploy-config/` at the gem root 2. `rake cm_admin:deploy_config:sync` copies everything inside `deploy-config/` to your app root 3. A `.deploy-config` marker file at your app root records a SHA256 checksum of the bundled config 4. On every boot in development, cm-admin compares the stored checksum against the current checksum of the gem's `deploy-config/` directory and warns if they differ ### Checksum Tracking The gem computes a SHA256 checksum of the `deploy-config/` directory at runtime. The `.deploy-config` marker file at the app root contains the checksum that was last synced: ``` a3f5c2... ``` No manual version bump is needed — changing any file inside `deploy-config/` produces a different checksum, which triggers the sync warning automatically. ### Available Tasks | Task | Command | Description | |------|---------|-------------| | Sync | `rake cm_admin:deploy_config:sync` | Copies everything from `deploy-config/` to app root and writes the marker | | Check | `rake cm_admin:deploy_config:check` | Fails (non-zero exit) if config is out of date — use in CI | ### Boot Check In development, cm-admin checks config sync on every boot. If the marker checksum is stale or missing, you'll see: ``` ⚠️ Deploy Config is out of date Run `rake cm_admin:deploy_config:sync` and commit the changes. ``` This never runs in production and never syncs automatically — you must run the sync task manually and commit the result. ### Workflow for App Developers When updating the cm-admin gem: 1. Run `bundle update cm-admin` 2. Start the app — if the boot warning appears, run `rake cm_admin:deploy_config:sync` 3. Commit the synced files (e.g. `.hatchbox/`, `.deploy-config`) 4. Deploy For CI, add a check after `bundle update`: ```bash bundle exec rake cm_admin:deploy_config:check ``` This fails the build if config is out of date, preventing deploying stale config. ### Workflow for Gem Developers #### Adding a New Config File or Folder 1. Create the file or folder inside `deploy-config/`: ``` deploy-config/ .hatchbox/ ← existing .new-tool/ ← new — just drop it in ├── setup.sh └── config.yml ``` 2. Commit and release a new gem version That's it — no code changes needed. The checksum changes automatically, so all apps will get the sync warning on next boot and `rake cm_admin:deploy_config:sync` will copy the new files to their app root. #### Modifying an Existing Config File 1. Edit the file inside `deploy-config/` (e.g. `deploy-config/.hatchbox/lib/config.rb`) 2. Commit and release #### Important Notes - **Sync is destructive**: `sync!` removes existing files at the target before copying. Any app-local modifications to synced files will be overwritten. - **No manual version bump needed**: The SHA256 checksum detects changes automatically when any file inside `deploy-config/` is added, removed, or modified. - **Commit after syncing**: The synced files must be committed to the app's git repo — deploy platforms like Hatchbox read from git, not from the gem at boot time. ### Current Contents | Path | Purpose | |------|---------| | `deploy-config/.hatchbox/` | Hatchbox deployment scripts with Slack notifications | See [HatchboxScripts](./HatchboxScripts.md) for details on the `.hatchbox/` config. --- ## **Guide to Deploying a Rails App to Hatchbox** --- ### **1️⃣ Prepare Your App Locally** 1. Make sure your Rails app runs locally without errors. 2. Commit all code to **GitHub** (Hatchbox pulls from Git). --- ### **2️⃣ Create a Cluster in Hatchbox** 1. Log in to **[Hatchbox.io](https://www.hatchbox.io)**. 2. Click **New Cluster**. 3. Fill in: **Page 1: Create New Cluster** * **Name**: Your cluster name (e.g., `Uddhava Production`). * **Hosting Provider**: AWS, DigitalOcean, Linode, etc. * **Region**: Choose the region closest to your users (e.g., `ap-south-1`). **Page 2: Connect to Service Provider (Example: AWS)** * **Name**: Account name to recognize it. * **Access Key ID**: Your AWS Access Key ID. * **Secret Access Key**: Your AWS Secret Access Key. * **Is this a GovCloud account?**: No. **Page 3: Choose Region** * **Region**: Choose the region closest to your users (e.g., `ap-south-1`). 4. Save the cluster. --- ### **3️⃣ Create a VPC Setting in Hatchbox** 1. In **Cluster** tab → Click the cluster or **New Server**. 2. If prompted, click **Create and Configure VPC for me**. 3. Choose any subnet and set AZ to **Let AWS choose for me**. 4. Save the cluster. --- ### **4️⃣ Create a Server in a Cluster in Hatchbox** 1. **Name**: Server name (e.g., `uddhava-production-server`). 2. **Size**: Choose server size (e.g., `t4g.small` for most apps). 3. **Volume**: Choose volume size (e.g., `50GB` for most apps). 4. **Role**: Choose server role (e.g., `Web Server`, `Background Worker`, `Cron Jobs`, `Database`, `Redis`). --- ### **5️⃣ Create an App in Hatchbox** 1. In **App** tab → Click **New App**. 2. Add: * **Cluster**: Choose your cluster. * **Name**: Your app name (e.g., `uddhava-production`). --- ### **6️⃣ Setting Up the App Repository in Hatchbox** 1. In **Current App** tab (the one you created). 2. Add: * **Git Host**: Connect to your host or select a pre-set host (e.g., `Commutatus`). * **Repository**: Your repository path (e.g., `commutatus/uddhava`). * **Branch**: Branch to deploy (e.g., `main`). 3. Set **Automatic Deploys**. --- ### **7️⃣ Creating Database in Hatchbox (Unmanaged)** 1. Create a PostgreSQL instance in AWS RDS: * Go to Databases tab in Current App → Click required Unmanaged Database. * For PostgreSQL: Provide DB name (e.g., uddhava_production). * For Redis: Just click create. * The database will be created and connected to your app via Environment Variables. --- ### **8️⃣ Assign Cron Jobs in Hatchbox** 1. Go to **Cron Jobs** tab in Current App → Click **New Cron Job**. 2. Add: * **Name**: Cron job name (e.g., `uddhava-production-cron`). * **Command**: Cron job command (e.g., `bundle exec wheneverize . && bundle exec whenever --update-crontab`). * **Schedule**: Cron job schedule (e.g., `*/5 * * * *`). --- ### **9️⃣ Adding Environment Variables in Hatchbox** 1. Go to **Environment Variables** tab in Current App → Click **New Environment Variable**. 2. Add: * **Name**: Environment variable name (e.g., `DATABASE_URL`). * **Value**: Environment variable value (e.g., `postgres://username:password@rds-endpoint:5432/uddhava_production`). 3. Click **Save Changes** after adding all variables. --- ### **🔟 Deploy the App to Hatchbox** 1. In Hatchbox → App → Click **Deploy**. 2. Hatchbox will: * Clone repo * Install Ruby + Gems * Precompile assets * Run migrations > **Note:** If errors occur, check **Process Logs** in Hatchbox. > Sidekiq and Rails server will be set up and started automatically. --- ### **1️⃣1️⃣ Domain & SSL** 1. In Hatchbox → App → **Domains** → Add your domain. 2. Point DNS to your Hatchbox on Route53. --- ### **Bonus: Setting Up AWS RDS (Managed)** 1. Go to **Database** tab in Current App → Click **New Database**. * Follow screenshots (`Screenshot_1.png`, `Screenshot_2.png`). * Choose the **VPC** selected in Hatchbox. * If prompted for subnets, create one in any remaining AZ in the VPC. 2. Go to **Environment Variables** in your app: ``` DATABASE_URL=postgres://USERNAME:PASSWORD@RDS-ENDPOINT:5432/uddhava_production ``` Save. --- ### **SSH into Your Server** 1. In Hatchbox → App → Click **SSH Keys** → Add your SSH key. 2. In the current app, go to **Process** and find the SSH command for the server. 3. Open Terminal and paste the SSH command. --- ### References Video Links: - [Part 1 Video][part1] - [Part 2 Video][part2] [part1]: https://commutatus-cm-docs.s3.ap-south-1.amazonaws.com/hatchbox-setup-part-1.mp4 [part2]: https://commutatus-cm-docs.s3.ap-south-1.amazonaws.com/hatchbox-setup-part-2.mp4 ✅ **After this, your app should be live with**: * **Main App Server** handling requests. * **Worker Server** running background jobs. * **AWS RDS** as the database. * **Managed by Hatchbox** for deployments. --- ## Hatchbox Deployment Scripts Version-control your Hatchbox build/deploy scripts. Hatchbox auto-detects these scripts during deployment. ### Setup 1. Run `rake cm_admin:deploy_config:sync` in your app to copy `.hatchbox/` to the app root (this also records the checksum marker `.deploy-config`) 2. Commit the changes and remove scripts from the Hatchbox dashboard ### Keeping Config in Sync Hatchbox reads `.hatchbox/` from the **deployed app's git repo** — gems installed via Bundler are never scanned. So when you update the `cm-admin` gem and its bundled config changes, sync it into your app: ```bash rake cm_admin:deploy_config:sync ``` The gem stores all deploy config files in a `deploy-config/` directory at the gem root. The sync task copies everything inside `deploy-config/` to your app root and writes a `.deploy-config` marker file tracking a SHA256 checksum of the bundled config. | Mechanism | When it runs | Behavior | |-----------|--------------|----------| | `rake cm_admin:deploy_config:sync` | Manual, after each `cm-admin` gem update | Copies everything from the gem's `deploy-config/` directory into the app root and writes the checksum marker (`.deploy-config` at app root) | | App boot check (development) | Every boot | Warns like the pending-migrations alert when the marker checksum differs from the gem's `deploy-config/` checksum — sync is never done automatically | | `rake cm_admin:deploy_config:check` | CI | Fails the build when config is out of date (add after `bundle update cm-admin`) | To add new deploy config files in the future, just drop them into `deploy-config/` in the gem source — no code changes needed. The checksum changes automatically and all apps will get the sync warning on next boot. After syncing, **commit the changes before deploying** — Hatchbox picks them up from git, not from a boot-time copy. ### Environment Variables | Variable | Used for | Example | | ----------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------- | | `APP_NAME` | App name shown in the notification message | `Uddhava Staging` | | `APP_URL` | "View Application" button | `https://myapp-staging.com` | | `SLACK_WEBHOOK_URL` | Slack webhook to post to | `https://hooks.slack.com/services/...` | | `HATCHBOX_APP_ID` + `LOG_ID` | "View Hatchbox Log" button | Provided by Hatchbox | | `DEPLOYMENT_ALERTS` | Set to `false` to skip notifications entirely | `true` | ### Configuration **`lib/config.rb`** - every value is read from the environment, so the scripts stay independent of the host app: ```ruby module Hatchbox module Config def self.app_name ENV.fetch('APP_NAME', nil) end def self.slack_webhook ENV.fetch('SLACK_WEBHOOK_URL', nil) end end end ``` Set `SLACK_WEBHOOK_URL` in your Hatchbox environment variables to the Slack channel/workspace you want to post to. If unset, deployment notifications are skipped and the script warns that the variable isn't set. ### Skipping Notifications Set `DEPLOYMENT_ALERTS` to `false` in your Hatchbox environment variables to stop deployment notifications from being sent during Hatchbox builds: ``` DEPLOYMENT_ALERTS=false ``` When it's disabled, `hatchbox_notification.rb` exits before sending any Slack notification. This is useful for suppressing notifications in certain environments (e.g. CI builds, preview apps). ### Files ``` .hatchbox/ ├── lib/ │ ├── config.rb # Env-var values for the notifications (APP_NAME, APP_URL, etc.) │ ├── event_types.rb # Slack payload definitions (don't touch) │ └── hatchbox_notification.rb # Notification entrypoint, run via ruby (don't touch) ├── pre-build # Deployment started ├── post-deploy # Deployment complete └── failed-deploy # Deployment failed ``` ### Slack Notification Format The scripts send compact Slack notifications with the following format: - **Title**: Deployment status header (Deployment Started, Deployment Complete, Deployment Failed) - **Message**: One line with the app name and status (e.g., ":hourglass_flowing_sand: Uddhava - Staging has started to deploy via Hatchbox.") - **Buttons**: - "View on Hatchbox" - Links to the Hatchbox deployment log page - "View Application" - Links to the deployed app (`APP_URL`) --- ## Release Changelog & Notifications cm-admin automates release notes generation, changelog docs updates, and Slack notifications as part of the gem release process. When a release is published, an AI-generated summary of the changes is posted to the docs site and to Slack — no manual changelog writing required. ### How It Works ``` release-cm-gem.yml (manual dispatch) └─ build & push gem └─ changelog-release.yml (reusable workflow) ├─ collect_context.sh → gathers git log, PR bodies, docs diff ├─ generate_release_notes.rb → calls OpenRouter, returns structured JSON ├─ Create GitHub Release → tag + AI-generated body ├─ update_docs_page.sh → prepends entry to monthly changelog page ├─ update_release_registry.rb→ updates docs-site/static/releases.json ├─ Commit & push to main → docs changes └─ notify_slack_release.rb → rich Slack notification ``` ### Workflow: `changelog-release.yml` A reusable workflow (`workflow_call`) invoked by `release-cm-gem.yml` after the gem is pushed. It receives the release version as input. | Input | Type | Description | | -------- | ------ | ------------------------------------ | | `version` | string | Release tag, e.g. `v7.2.26` | #### Required Secrets | Secret | Purpose | | -------------------- | ---------------------------------------------------- | | `OPENROUTER_API_KEY` | Authenticates the release-notes generation call | | `SLACK_WEBHOOK_URL` | Incoming webhook for the Slack notification | | `MARVIN_GH_PAT` | PAT used to check out, create the release, and push docs commits to `main` | #### Optional Variables | Variable | Purpose | | ------------------ | -------------------------------------------------------------- | | `OPENROUTER_MODEL` | Overrides the default model (`openai/gpt-6-luna`) | ### Components #### `collect_context.sh` Gathers everything the AI needs into `/tmp/release-context/`: - **`git_log`** — commits since the previous tag (up to 50) - **`pr_bodies`** — merged PR titles and bodies since the previous tag's commit date (via `gh pr list --search "merged:>="`) - **`docs_diff`** — diff stat and content of `docs-site/` changes since the previous tag #### `generate_release_notes.rb` Sends the collected context to OpenRouter with a strict JSON schema (`response_format`) and prints the result to stdout: ```json { "docs_markdown": "...", // full changelog page content "slack_highlights": "...", // 1-2 sentence paragraph for the ✨ Highlights section "slack_details": ["..."], // bullet items for the 📋 Details section "warning": "..." | null, // breaking-change warning, if any "migration_commands": ["..."] | null } ``` If `OPENROUTER_API_KEY` is unset or the API call fails, the script falls back to raw git-log output so the release never blocks on AI availability. #### `update_docs_page.sh` Prepends a `## ()` section to the current month's changelog page at `docs-site/docs/Changelog/.md`. If the file doesn't exist yet, it creates it with frontmatter (`sidebar_position` derived from the year-month) and a `# Release Notes — ` header. #### `update_release_registry.rb` Appends release metadata to `docs-site/static/releases.json`: ```json { "version": "7.2.26", "date": "2026-09-07", "has_breaking_changes": false, "has_migrations": false, "docs_url": "https://docs.cm-admin.commutatus.com/docs/Changelog/2026-09" } ``` Entries are deduplicated by version and sorted with `Gem::Version` semantics. #### `notify_slack_release.rb` Posts a Block Kit message to the Slack webhook containing: - Header with the version - ✨ Highlights paragraph summarizing the release - ⚠️ Breaking Changes block (shows a "✅ No breaking changes" fallback when there are none) - 📋 Details bullets with emoji and PR links - Buttons linking to the GitHub release and the changelog docs page - Context footer with the release date ### Prompt Template The AI prompt lives at `.github/prompts/prompt_template.txt`. It instructs the model to produce categorized release notes (🚀 Features, 🐛 Bug Fixes, ✨ Improvements, ⚠️ Breaking Changes, 📚 Documentation) plus Slack content: a highlights paragraph, detail bullets with PR links, and breaking-change/migration info. The Slack Blocks themselves are built by `notify_slack_release.rb`. Edit this file to tune the tone or structure of generated notes. ### Changelog Docs Generated pages live under `docs-site/docs/Changelog/`, one file per month (`2026-09.md`), with an `index.md` landing page. Each release prepends its section, so the newest release appears first within a month. --- ## Releasing the Gem cm-admin is published to the private **CM Gems** registry via two manually dispatched GitHub workflows: a stable release from `main` and a beta release from `beta`. ### Stable Release — `release-cm-gem.yml` **Workflow name**: Bump Gem to CM Gems **Trigger**: Manual (`workflow_dispatch`) — must run on the `main` branch #### Input | Input | Type | Options | Default | Description | | ----------- | ------ | ------------------------ | ------- | -------------------------------------------------- | | `bump_type` | choice | `patch`, `minor`, `major` | `patch` | Semantic version segment to bump via `gem-release` | #### Pipeline ``` build (main branch only) ├─ Checkout with MARVIN_GH_PAT ├─ Set up Ruby 3.4 + bundler cache ├─ gem install gem-release ├─ gem bump --version → updates lib/cm_admin/version.rb ├─ bundle install → refreshes Gemfile.lock └─ Commit & push to main → "Add bundle files" release (needs: build) ├─ Checkout + git pull ├─ gem build cm_admin.gemspec ├─ Read version from CmAdmin::VERSION → exposed as job output └─ ./upload.sh $CM_GEMS_PASSWORD $CM_GEMS_HOST changelog (needs: release) └─ Calls changelog-release.yml with version=v → AI release notes, GitHub Release, docs update, Slack notification (see Release Changelog & Notifications) ``` #### Secrets & Variables | Name | Kind | Purpose | | ------------------ | ------- | -------------------------------------------- | | `MARVIN_GH_PAT` | secret | Checkout and push version-bump commits | | `CM_GEMS_PASSWORD` | var | Password for the private gem host | | `CM_GEMS_HOST` | var | Hostname of the private gem registry | | `OPENROUTER_API_KEY`, `SLACK_WEBHOOK_URL` | secret | Passed through to the changelog workflow | ### Beta Release — `release-beta-gem.yml` **Workflow name**: Release Beta Version to CM Gems **Trigger**: Manual (`workflow_dispatch`) — must run on the `beta` branch #### How Beta Versioning Works 1. Reads the base version from `CmAdmin::VERSION` and strips any existing `.beta` suffix 2. Counts existing git tags matching `v.beta*` to determine the next beta number 3. Writes `.beta` into `lib/cm_admin/version.rb` (e.g. `7.2.26.beta1`, `7.2.26.beta2`, …) 4. Commits the version bump to `beta`, builds the gem, and pushes to CM Gems 5. Creates a GitHub **pre-release** tag `v.beta` via `softprops/action-gh-release` Beta releases do not trigger the changelog workflow — they are pre-releases for testing only. ### Notes - Both workflows abort early if dispatched from the wrong branch (`main` / `beta` respectively) - `bundle config set frozen false` is set before `bundle install` so `Gemfile.lock` can be updated during the bump - The stable release's changelog step runs **after** the gem is pushed, so a changelog failure never blocks the gem publish --- ## PR Review Apps Every non-draft pull request to `main` gets its own running copy of `feature-app/`, built from the PR's code, at: ``` https://pr-.catalyst.platforms.commutatus.com ``` Reviewers can click through the change in a real app instead of pulling the branch locally. The app is redeployed on every push and destroyed when the PR is closed or merged. ### At a Glance | Piece | What it is | Where it's defined | | ---------------- | ------------------------------------------------------------------ | --------------------------------------------- | | Workflow | GitHub Actions job that deploys and tears down review apps | `.github/workflows/review-app.yml` | | Fly app | `cm-admin-pr-`: Puma, Sidekiq and Redis on one machine | `feature-app/fly.review.toml` | | Docker image | Built from the repo root so the gem source is this PR's checkout | `feature-app/Dockerfile.review` | | Database | Neon branch `pr-`, one per PR | Created by the workflow | | File uploads | Tigris bucket `cm-admin-pr-`, one per PR | Created by the workflow, `config/storage.yml` | | Router | `cm-admin-review-router`: Caddy that maps each `pr-*` host to its app | `feature-app/review-router/` | | Rails env | `review`, which behaves like staging | `feature-app/config/environments/review.rb` | | Seeds | Shared seeds plus the reviewer accounts | `feature-app/db/seeds/review.rb` | ### Lifecycle ``` PR opened / reopened / pushed to / marked ready for review └─ deploy job ├─ Create Neon branch pr- (reused if it exists) ├─ Create Fly app cm-admin-pr- (reused if it exists) ├─ Create Tigris bucket (reused if it exists) ├─ Stage secrets: DATABASE_URL, POSTMARK_API_TOKEN, APP_HOST, │ SECRET_KEY_BASE (first deploy only) ├─ flyctl deploy (remote build of Dockerfile.review) │ └─ release command: bin/review-app-release │ ├─ bin/rails db:prepare (schema + seeds on first run, migrations after) │ └─ bin/rails db:seed (fills in cm_admin permissions) ├─ Set the Tigris bucket's CORS rules (so browsers can direct-upload to it) ├─ Allocate a private Flycast address (so the router can reach the app) └─ Post or update the "Review app:" comment on the PR PR closed or merged └─ teardown job ├─ Destroy the Tigris bucket and the Fly app └─ Delete the Neon branch ``` Every step in the deploy job is safe to repeat, so a push to an existing PR simply redeploys the same app against the same database. Pushes that arrive while a deploy is running cancel it, so only the latest commit is deployed. #### When a PR does *not* get a review app The deploy job is skipped when: - the PR is a **draft** (marking it ready for review triggers the deploy); - the PR comes from a **fork** or from **Dependabot**, since GitHub doesn't give those runs the Actions secrets; - the PR targets a branch other than `main`; - every changed file is documentation: `**/*.md`, `**/*.mdx`, `docs/**` or `docs-site/**`. ### Using a Review App 1. Open the link in the **Review app** comment on the PR. The comment is updated in place on each deploy and shows the commit SHA it was built from. 2. Sign in with your email address. It must be one of the users listed in `feature-app/db/seeds/review.rb`. 3. Enter the one-time code that is emailed to you. Things to keep in mind: - **It sleeps when idle.** Fly suspends the machine after a period without traffic, and the first request afterwards can take a few seconds while it wakes. - **Emails are really sent** through Postmark. Don't trigger mail to addresses outside the team. - **Data persists across pushes.** The Neon branch and Tigris bucket live as long as the PR, so records and uploads you create survive redeploys. Sessions do too, as `SECRET_KEY_BASE` is generated once per app. - **Background jobs run.** Sidekiq runs inside Puma (`EMBEDDED_SIDEKIQ=true`), using the `default` and `mailers` queues. #### Adding a reviewer Add an entry to the `users` list in `feature-app/db/seeds/review.rb` and merge it to `main`. The seeds run on every deploy and only create users that don't exist yet, so open PRs pick the new user up on their next push once they include the change. ### How It Fits Together #### Routing ``` Browser ── https://pr-42.catalyst.platforms.commutatus.com │ ▼ Fly edge (TLS, wildcard certificate *.catalyst.platforms.commutatus.com) │ ▼ cm-admin-review-router (Caddy) │ Host ^pr-(\d+)\.catalyst… → cm-admin-pr-42.flycast:80 ▼ cm-admin-pr-42 (Puma on :3000) ``` The router holds a single wildcard certificate, so review apps need no certificate or DNS record of their own and don't count towards Let's Encrypt's weekly limit for the domain. It reaches each app over **Flycast** (Fly's private network), which is HTTP-only, so HTTPS is forced at the router and Caddy passes `X-Forwarded-Proto: https` on to Rails. Requests through Flycast wake a suspended app just as public requests do. Any host that doesn't match the pattern gets a 404 saying "No review app here." Each app is also reachable directly at `https://cm-admin-pr-.fly.dev`. #### The image `Dockerfile.review` is built with the **repo root** as its context, so `feature-app`'s `gem 'cm-admin', path: '../'` uses the PR's version of the gem. In particular: - `config/database.review.yml` is copied over `config/database.yml`. - `bundle install` is not frozen, because `Gemfile.lock` can lag behind the gem version in the checkout. - `assets:precompile` also runs `cm_admin:scss:build`, which needs npm in the repo root. - `bin/review-app-start` is the entrypoint. It starts Redis inside the machine before Puma, so Redis suspends and resumes along with the app. `Dockerfile.review.dockerignore` keeps `.git`, `node_modules`, logs, master keys and the docs site out of the build context. Because `.git` is excluded, `cm_admin.gemspec` falls back to a file glob for its file list. #### The `review` environment `config/environments/review.rb` loads the staging config and then: - points `db:seed` at `db/seeds/review.rb`; - reads the Postmark token from the `POSTMARK_API_TOKEN` Fly secret, since only development credentials are committed; - builds mailer links from `APP_HOST`; - stores Active Storage uploads in the app's Tigris bucket. #### The database Each PR gets a Neon branch named `pr-`, branched from the project's default branch. The app connects with the **direct (unpooled)** URL, because PgBouncer's transaction mode breaks prepared statements and migration advisory locks. ### Configuration #### Repository secrets and variables | Name | Type | Used for | | -------------------- | -------- | --------------------------------------------------------- | | `FLY_API_TOKEN` | Secret | Creating, deploying and destroying Fly apps and buckets | | `NEON_API_KEY` | Secret | Creating and deleting Neon branches | | `POSTMARK_API_TOKEN` | Secret | Sending OTP and other emails from review apps | | `FLY_ORG` | Variable | Fly organisation that review apps are created in | | `NEON_PROJECT_ID` | Variable | Neon project that PR branches are created in | #### One-off router setup The router has to exist before any review app is reachable on the custom domain. From `feature-app/review-router/`: ```bash fly apps create cm-admin-review-router --org fly deploy fly certs add "*.catalyst.platforms.commutatus.com" ``` Then add two records in Route 53: 1. the `_acme-challenge` CNAME that `fly certs setup "*.catalyst.platforms.commutatus.com"` prints; 2. `*.catalyst.platforms.commutatus.com` CNAME `cm-admin-review-router.fly.dev`. After changing the `Caddyfile`, run `fly deploy` in that directory again. ### Troubleshooting | Symptom | Likely cause and fix | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | No **Review app** comment on the PR | The PR is a draft, from a fork or Dependabot, or only touches docs. Check the "Review app" workflow run in the Actions tab. | | "No review app here." (404) | The Fly app doesn't exist yet or the host is mistyped. Wait for the deploy job to finish. | | Deploy fails in the release command | A migration or seed failed. Check the release logs with `fly logs --app cm-admin-pr-`. | | The domain hangs or errors but `fly.dev` works | The app has no Flycast address, or the router is down. Re-run the workflow, or check `fly status --app cm-admin-review-router`. | | No OTP email arrives | Your email isn't in `db/seeds/review.rb`, or the `POSTMARK_API_TOKEN` secret is wrong. | | First request is slow | Expected: the machine was suspended and is waking up. | | Need a clean database | Delete the `pr-` branch in Neon and push to the PR (or re-run the workflow). The deploy creates a fresh branch and seeds it. | | Resources left behind after a PR closed | The teardown job tolerates missing resources but can still fail. Re-run it from the Actions tab, or destroy `cm-admin-pr-` and the Neon branch by hand. | --- ## Deployment Documentation Guides for deploying Rails applications to various hosting platforms. ### Available Guides - **[Deploy on Hatchbox](./DeployOnHatchbox.md)** - Step-by-step guide for deploying Rails apps to Hatchbox with AWS infrastructure - **[Catalyst Config](./CatalystConfig.md)** - How cm-admin bundles and syncs deploy config files from the gem into your app - **[Hatchbox Deployment Scripts](./HatchboxScripts.md)** - Version-controlled build/deploy scripts with Slack notifications - **[PR Review Apps](./ReviewApps.md)** - How every pull request gets its own deployed copy of the feature app, and how it is torn down --- ## Upgrade Guide This guide helps you upgrade between versions of cm-admin. Each version has its own guide listing breaking changes, method renames, and migration steps. ### Versions - [v9.0.0](./v9.0.0.md) - [v8.4.1](./v8.4.1.md) - [v8.4.0](./v8.4.0.md) - [v8.3.3](./v8.3.3.md) - [v8.2.18](./v8.2.18.md) - [v8.2.0](./v8.2.0.md) - [v8.0.3](./v8.0.3.md) - [v8.0.0](./v8.0.0.md) --- ## v8.0.0 ### Breaking Changes #### Model Configuration Methods — Renamed Model configuration uses declarative methods inside the `cm_admin do` block. Pass a value to configure an option; calling the same method without an argument returns its current value. | Before | After | | --------------------------------- | ----------------------------------- | | `set_icon 'fa fa-file'` | `icon_name 'fa fa-file'` | | `set_display_name 'Blog Post'` | `display_name 'Blog Post'` | | `set_policy_scopes [...]` | `policy_scopes [...]` | | `set_default_scope [...]` | `default_scopes [...]` | | `self.comments_tab = {...}` | `comments_tab mentions_model: User` | | `visible_on_sidebar false` | `visible_on_sidebar false` | | `permit_additional_fields [...]` | `additional_permitted_fields [...]` | | `override_pundit_policy true` | `override_policy true` | | `self.mcp_enabled = true` | `mcp_enabled true` | | `self.description = '...'` | `description '...'` | | `self.global_search_fields = ...` | `global_search_fields [...]` | Standard Ruby assignment remains available for programmatic configuration outside the DSL, for example `model.icon_name = 'fa fa-file'`. #### Internal Method Renames These are internal changes. If you were reading these values directly, update references: | Before | After | | ----------------------------- | ----------------------------- | | `model.is_visible_on_sidebar` | `model.visible_on_sidebar` | | `model.mcp_action_list` | `model.available_mcp_actions` | :::note The `mcp_actions` DSL method (used inside `cm_admin do` blocks) is unchanged. Only the internal reader was renamed. ::: #### Sort Configuration — Use `sort` for Each Column The `sortable_columns`, `sort_column`, and `sort_direction` methods have been removed. Define each sortable column with a separate `sort` call instead. ```ruby # Before sortable_columns [ { column: 'name', display_name: 'Name' }, { column: 'created_at', display_name: 'Created At', default: true, default_direction: 'desc' }, { column: 'metadata_updated_at', display_name: 'Metadata Updated At', sort_datatype: 'TIMESTAMP' } ] # After sort column: 'name', display_name: 'Name' sort column: 'created_at', display_name: 'Created At', default: true, default_direction: 'desc' sort column: 'metadata_updated_at', display_name: 'Metadata Updated At', datatype: 'TIMESTAMP' ``` The `sort_datatype` option has also been renamed to `datatype`. If you previously configured the default sort separately, combine it into one `sort` declaration: ```ruby # Before sort_column :created_at sort_direction :desc # After sort column: 'created_at', default: true, default_direction: 'desc' ``` :::warning Sort query parameters now use the nested `sort[column][dir]` format to support multiple sort columns. Update any saved URLs or integrations that construct the old `sort_column`, `sort_direction`, or `sort_datatype` parameters. ::: #### Tag Fields Renamed to Badge Fields The `:tag` field type and `tag_class` option have been renamed to `:badge` and `badge_class`. Update both index columns and show-page fields. ```ruby # Before column :status, field_type: :tag, tag_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' } field :status, field_type: :tag, tag_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' } # After column :status, field_type: :badge, badge_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' } field :status, field_type: :badge, badge_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' } ``` The supported CSS class names remain `cm-badge-blue`, `cm-badge-yellow`, `cm-badge-orange`, `cm-badge-purple`, `cm-badge-cyan`, `cm-badge-green`, `cm-badge-gray`, and `cm-badge-red`. #### Migration Steps 1. Update all model concern files. Here is a before/after example: ```ruby # Before cm_admin do actions except: %i[import] set_display_name 'Cron Job' set_icon 'fa-regular fa-clock' visible_on_sidebar false set_policy_scopes [{ scope_name: 'by_current_user', display_name: 'User Only' }] permit_additional_fields [category_ids: []] end # After cm_admin do actions except: %i[import] display_name 'Cron Job' icon_name 'fa-regular fa-clock' visible_on_sidebar false policy_scopes [{ scope_name: 'by_current_user', display_name: 'User Only' }] additional_permitted_fields [category_ids: []] end ``` 2. Replace `sortable_columns`, `sort_column`, and `sort_direction` with one `sort` declaration per sortable column 3. Replace `sort_datatype` with `datatype` and update integrations that construct sort query parameters 4. Replace `field_type: :tag` with `field_type: :badge` and `tag_class` with `badge_class` 5. If you reference `is_visible_on_sidebar` or `mcp_action_list` anywhere in your app, update those to `visible_on_sidebar` and `available_mcp_actions` #### Migration Template File Extensions All migration template files in generators have been renamed from `.rb` to `.tt` extensions to follow Rails conventions. **Impact:** - This change only affects generator templates, not existing migrations - No action required for existing applications --- ## v8.0.3 ### Breaking Changes #### `FileImport` and `FileExport` Models Renamed The built-in `FileImport` and `FileExport` models have been renamed to `CtFileImport` and `CtFileExport` to avoid colliding with identically named models in host applications. Every public surface that used the old names has been renamed: | Before | After | | -------------------------------------------- | ------------------------------------------------ | | `FileImport` / `FileExport` models | `CtFileImport` / `CtFileExport` | | `file_imports` / `file_exports` tables | `ct_file_imports` / `ct_file_exports` | | `CmAdmin::FileImport` concern | `CmAdmin::CtFileImport` | | `CmAdmin::FileExport` concern | `CmAdmin::CtFileExport` | | `FileImportPolicy` | `CtFileImportPolicy` | | `cm_*_file_import_path` route helpers | `cm_*_ct_file_import_path` | | `cm_*_file_export_path` route helpers | `cm_*_ct_file_export_path` | | `file_import[...]` form/param key | `ct_file_import[...]` | | `included_models = [FileImport, FileExport]` | `included_models = [CtFileImport, CtFileExport]` | ##### Migration Steps 1. Run the rename generator to create a migration for your application: ```bash rails generate cm_admin:rename_file_import_and_export_tables rails db:migrate ``` The generated migration renames `file_imports` to `ct_file_imports` and `file_exports` to `ct_file_exports`. It also rewrites stored model-name references in `active_storage_attachments.record_type`, `versions.item_type`, `cm_permissions.ar_model_name`, and `cm_index_preferences.ar_model_name`. 2. Update `config.included_models` in `config/initializers/zcm_admin.rb` if you list the old constants: ```ruby # Before config.included_models = [FileImport, FileExport] # After config.included_models = [CtFileImport, CtFileExport] ``` 3. Update any references in your application code: - Constant references: `FileImport`, `FileExport`, `FileImportPolicy` - Associations such as `has_many :file_exports` or `belongs_to :file_import` - Route helpers like `cm_show_file_export_path` or `cm_index_file_import_path` - Custom import forms or API clients that post a `file_import[...]` param key — the key is now `ct_file_import[...]` - Factories, seeds, or permission records that store `FileImport`/`FileExport` model names :::note The `file_import:` keyword argument on importer classes (`initialize(file_import:)`) and `CmAdmin::BaseImporter`'s `file_import` reader are unchanged — only the model, table, route, and param names were renamed. ::: ### New Configuration #### `config.user_class` — Configurable User Model Hardcoded `::User` references have been replaced with `CmAdmin.config.user_class`, which defaults to `'User'`. If your application uses a different user model, set it in the initializer: ```ruby CmAdmin.configure do |config| config.user_class = 'AdminUser' # or config.user_class = AdminUser end ``` The value is lazily constantized, so it can be set before the model is loaded or after a Rails code reload. It is used by the default `user_lookup` (OTP and password auth), the `Trackable` concern's `created_by`/`updated_by` associations, `CmRole#users`, `CmEmailLog#triggered_by`, `CmSupportTicket` associations and search, and the comments/mentions model. **Impact:** - No action required if your user model is `User`. - If your app already overrides `user_lookup` for auth, consider setting `user_class` as well — other features (mentions, history, support tickets) read it independently of `user_lookup`. ### Dependencies #### Devise Is Now a Required Dependency `devise (>= 4.9)` has moved from an opt-in extra to a required dependency and is loaded by the engine at boot (`ostruct >= 0.6.0` was also added). Bundler installs it automatically, but note: - If your Gemfile pins `devise` below 4.9, relax the constraint. - Apps that don't use Devise still work — `auth_method :custom` remains available — but Devise will now be loaded in your process. ### Internal Changes #### Removed `$available_actions` Global Variable `CmAdmin::Model` previously stored the model's action list in a process-wide global variable, `$available_actions`, while dynamically generating the model's controller and Pundit policy. The generated classes now use the model's own `available_actions` attribute, and the global variable is no longer set. **Impact:** - No action required for standard applications — this is an internal refactor with no public API changes. - Removing the global fixes a subtle issue where `$available_actions` retained the last-initialized model's actions for the lifetime of the process and could be shared across models defined in the same process. - If your application references `$available_actions` directly (for example, in an initializer or monkey patch), read the model's `available_actions` attribute instead: ```ruby # Before $available_actions # After CmAdmin::Model.find_by(name: 'User').available_actions ``` --- ## v8.2.0 This release adds [**Advanced Filters**](../3.%20Features/AdvancedFilters.md) (nested AND/OR condition groups with operators such as `is_not`, `greater_than`, `is_empty`) and [**Saved Views**](../3.%20Features/SavedViews.md), which replace Index Preferences / Manage Columns. Saved views store filters, sort, visible columns, records per page, and view type for each user. ### Breaking Changes #### `filter_with` Scopes Now Receive a `condition` Argument Every scope referenced by `filter_with` on a `:single_select`, `:multi_select`, `:date`, or `:range` filter is now called with **two arguments**: the filter value and the selected operator (`condition`). | Filter type | Before | After | | --------------------------------- | ------------------------------- | --------------------------------------- | | `:single_select`, `:multi_select` | `records.send(scope, value)` | `records.send(scope, value, condition)` | | `:date`, `:range` | `records.send(scope, from, to)` | `records.send(scope, value, condition)` | | `:search` | `records.send(scope, value)` | Unchanged | The date and range behaviour has changed most: - `value` is now the **raw string**, for example `"2026-01-01 to 2026-01-31"` or `"18 to 30"`. Previously the gem split it into `from` and `to`. - For single-value operators (`greater_than`, `less_than`, `is`, …), `value` holds a single value with no `to`. `condition` is `nil` for the standard filter bar. It is set only when the filter is used inside Advanced Filters. :::danger Scopes defined with `lambda` / `->` enforce arity strictly. A scope that still takes one argument (or `from, to`) raises `ArgumentError` when filtering. **Update every `filter_with` scope before upgrading.** ::: ##### Available conditions | Filter type | Conditions | Default | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | | `:single_select`, `:multi_select` | `is`, `is_not`, `is_empty`, `is_not_empty` | `is` | | `:date` | `between`, `not_between`, `is_empty`, `is_not_empty` | `between` | | `:range` | `between`, `not_between`, `is`, `is_not`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | `between` | Your scope is still called with `is_empty` and `is_not_empty` even when `value` is blank. Handle these conditions without relying on `value`. ##### Migration Steps Add a `condition` parameter to every `filter_with` scope. Make it optional (`condition = nil`) so that existing direct callers keep working. **Select filters:** ```ruby # Before scope :custom_status_filter, ->(statuses) { where(status: statuses) } # After scope :custom_status_filter, lambda { |statuses, condition = nil| case condition.to_s when 'is_not' then where.not(status: statuses) when 'is_empty' then where(status: nil) when 'is_not_empty' then where.not(status: nil) else where(status: statuses) end } ``` **Date / range filters:** ```ruby # Before scope :custom_age_range, lambda { |from, to| where('age BETWEEN ? AND ?', from.to_i, to.to_i) if from.present? && to.present? } # After scope :custom_age_range, lambda { |value, condition = nil| from, to = value.to_s.split(' to ') case condition.to_s when 'not_between' then where.not(age: from.to_i..to.to_i) when 'greater_than' then where('age > ?', from.to_i) when 'is_empty' then where(age: nil) # ...handle the other operators you want to support else where(age: from.to_i..to.to_i) end } ``` If your scope only wraps a column with extra joins or a different column, delegate the operator handling to the new helpers. They are available on every ActiveRecord model through `CmAdmin::FilterConditions`: ```ruby # Filter users by the country of their company (users -> companies) scope :by_company_country, lambda { |value, condition = nil| left_joins(:company).ct_dropdown_filter_condition(column: :country, value:, condition:, table: 'companies') } # Filter users by the size of their company scope :by_company_size, lambda { |value, condition = nil| left_joins(:company).ct_range_filter_condition(column: :employee_count, value:, condition:, table: 'companies') } ``` ```ruby filter :company_country, :multi_select, helper_method: :company_country_options, filter_with: :by_company_country filter :company_size, :range, filter_with: :by_company_size ``` `table:` defaults to the model's own table. Pass the joined table's name whenever the column lives on an association. :::tip Use `left_joins` rather than `joins` when you support `is_empty`. With an inner `joins`, records that have no associated row are dropped before the `IS NULL` check runs. ::: | Helper | Handles | | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | | `ct_dropdown_filter_condition(column:, value:, condition:, table: table_name)` | `is`, `is_not`, `is_empty`, `is_not_empty`. Maps `cm_empty` to `NULL` | | `ct_range_filter_condition(column:, value:, condition:, table: table_name)` | All date and range operators. Parses the `"from to to"` string for you | #### Index Preferences Replaced by Saved Page Views The `CmIndexPreference` model and the `cm_index_preferences` table have been removed. Column visibility, records per page, filters, sort, and view type are now saved per user as named **page views** in a new `ct_page_views` table. ##### Migration Steps 1. Run the [CT Page View generator](../Generators.md#17-ct-page-view-generator), then migrate: ```bash rails generate cm_admin:ct_page_view rails db:migrate ``` The generated migration: - Enables the PostgreSQL `citext` extension if it is not already enabled. The view name is case-insensitive. - **Drops the `cm_index_preferences` table** if it exists. - Creates `ct_page_views` with a polymorphic `owner`, `name`, `ar_model_name`, `associated_ar_model_name`, `visible_columns`, `hidden_columns`, `is_default`, `view_type`, `records_per_page`, `filter_params` (jsonb), and `sort_params` (json). - Adds a partial unique index so that each owner has at most one default view per model / associated model. :::warning Existing column preferences in `cm_index_preferences` are **not migrated**. Users have to recreate their column layouts as saved views. If you need to keep the data, back up the table or copy it into `ct_page_views` before running the migration. ::: 2. Remove any references to the old index preference API from your application: | Removed | Replacement | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `CmIndexPreference` model | `CtPageView` | | `cm_index_preference__path` route helper | `create_ct_page_view__path` / `update_ct_page_view__path` | | `reset_cm_index_preference__path` route helper | Clear the active view (`?clear_ct_page_view=true`) | | `cm_index_preference` / `reset_cm_index_preference` controller actions | `list_ct_page_view`, `create_ct_page_view`, `update_ct_page_view`, `destroy_ct_page_view`, `duplicate_ct_page_view` | | `CmAdmin::ViewHelpers::IndexPreferencePopupHelper` | Partials under `cm_admin/page_view/` and `cm_admin/main/_manage_columns_modal` | | `@cm_index_preference` instance variable | `@current_ct_page_view` | 3. Make sure the following are present in your host app. Most cm-admin installs already have them: - The `DEFAULT_PER_PAGE` constant (generated in `config/initializers/constants.rb`). `CtPageView` references it at load time. `PER_PAGE_OPTIONS` is optional and defaults to `[10, 20, 50, 100]`. - `Current.user` is set for admin requests. It becomes the `owner` of each saved view. - PaperTrail's `versions` table. `CtPageView` declares `has_paper_trail`. 4. Remove `cm_index_preferences` from `db/schema.rb` / `structure.sql` once migrated, and remove any factories, seeds, or specs that build `CmIndexPreference` records. ##### New routes These routes are added automatically for every cm-admin model, with no setup needed: | Verb | Path | Action | | ------ | --------------------------------------- | ------------------------ | | GET | `//ct_page_views` | `list_ct_page_view` | | POST | `//ct_page_views` | `create_ct_page_view` | | PUT | `//ct_page_views/:id` | `update_ct_page_view` | | DELETE | `//ct_page_views/:id` | `destroy_ct_page_view` | | POST | `//ct_page_views/:id/duplicate` | `duplicate_ct_page_view` | All actions are scoped to `owner: Current.user`, so users can only see and change their own views. ##### Default views A user can mark one view per list page as **default**. When that user opens the index page with no `filters`, `current_ct_page_view_id`, `clear_filters`, or `clear_ct_page_view` params, they are redirected to their default view. Deep links that include any of those params are not affected. ### Behaviour Changes #### `filters` URL Param Is Now Compressed The `filters` query param on index and dashboard pages is now a zlib-compressed, base64url-encoded JSON string (produced with `pako` on the frontend), not a nested `filters[...]` hash. Compressing it keeps URLs short now that Advanced Filters can contain nested groups. - **Old links still work.** A nested `filters[...]` hash or a plain JSON string is still accepted. - If your application code reads `params[:filters]` directly (custom controllers, helpers, `table_source_method`s, export hooks), decode it first: ```ruby # Before filters = JSON.parse(params[:filters] || '{}') # After filters = CmAdmin::Utils.decode_filter_params(params[:filters]) || {} ``` - To generate a filtered link from Ruby, encode the hash: ```ruby cm_admin.cm_index_user_path(filters: CmAdmin::Utils.encode_filter_params({ single_select: { status: 'active' } })) ``` Dashboard widget source methods (charts, metrics, tables) still receive a decoded filters hash, so no change is needed there. #### Sidebar `query` Items Linking to Filtered Index Pages If your `config.sidebar` entries open a pre-filtered index page through `query:`, prefer moving that default into the filter's `default_value:` option instead of passing filters in the URL: ```ruby # Before — sidebar entry carrying a filters hash { display_name: 'Draft Posts', path: 'cm_index_post_path', query: { filters: { single_select: { status: 'draft' } } } } # After — the filter applies itself whenever no filter params are present filter :status, :single_select, collection: %w[draft published], default_value: -> { 'draft' } ``` A `filters` hash in `query:` also suppresses the default-view redirect on every visit, so saved views never apply when the page is opened through that entry. If a sidebar entry must keep its `query:` — for example, several entries pointing at different filtered states of the same index — include the current user's default view keys so the page resolves the correct view context: ```ruby { display_name: 'Posts', path: 'cm_index_post_path', query: lambda { view = CtPageView.find_by(owner: Current.user, is_default: true, ar_model_name: 'Post') { current_ct_page_view_id: view&.id, filters: view&.filter_params } } } ``` `query` accepts a proc that is evaluated on each request, which is required here because `Current.user` is only set while rendering. `filter_params` is a plain hash, so it serializes back into the nested `filters[...]` URL format, which is still supported. :::tip `CtPageView#apply_params` returns the same keys (`current_ct_page_view_id`, `filters`, `sort`, `view_type`) already URL-ready, so `query: -> { CtPageView.find_by(...)&.apply_params || {} }` is a shorthand for the above. ::: #### Advanced Filters Override Standard Filters When a request contains `advanced` filter conditions, all standard filter keys (`date`, `range`, `single_select`, `multi_select`) **and their `default_value`s** are ignored. Only `search` and `advanced` are applied. Exports follow the same rule. If you rely on a `default_value` filter always being applied, enforce it with a policy scope or default scope instead. #### `Model#default_filters` Signature `default_filters` now also accepts a `filter_list:` keyword, alongside the existing `action:`. Existing calls such as `model.default_filters(action:)` keep working. #### `available_fields` Uses Indifferent Access `CmAdmin::Model#available_fields` is now a `HashWithIndifferentAccess`. Both `available_fields[:index]` and `available_fields['index']` work. No action is needed. ### Checklist 1. Update every application `filter_with` scope for `select`, `date`, and `range` filters to accept `(value, condition = nil)`. 2. Run `rails generate cm_admin:ct_page_view && rails db:migrate`. Back up `cm_index_preferences` first if you need the data. 3. Remove references to `CmIndexPreference`, `cm_index_preference_*` / `reset_cm_index_preference_*` route helpers, and `@cm_index_preference`. 4. Confirm that `DEFAULT_PER_PAGE`, `Current.user`, and PaperTrail are available. 5. Replace any direct `JSON.parse(params[:filters])` with `CmAdmin::Utils.decode_filter_params(params[:filters])`. 6. Review sidebar `query:` entries that inject `filters` — prefer `default_value:` on the filter, or include `current_ct_page_view_id` and `filters` from the user's default `CtPageView`. --- ## v8.2.18 This release adds a required `name` field to API tokens, replacing the `description` field introduced in v8.2.14. ### Breaking Changes #### API Token `description` Replaced by Required `name` API tokens now have a required `name` field (string, max 100 characters) to identify what each token is used for. This replaces the `description` field introduced in v8.2.14 — the `cm_admin:add_description_to_api_token` generator has been removed, so run the name generator below even if you already generated the description migration. The index page lists `name` as the first column and adds a search-by-name filter. A new Edit page lets users rename a token; the token value and other fields stay read-only. ##### Migration Steps 1. Run the [Add Name to API Token generator](../Generators.md#21-add-name-to-api-token-generator), then migrate: ```bash rails generate cm_admin:add_name_to_api_token rails db:migrate ``` The generated migration: - Adds a `name` string column to `api_tokens` (skipped if the column already exists). - Backfills existing records with `'Unnamed Token'`. - Drops the `description` column if it exists — this cleans up apps that ran v8.2.14's `add_description_to_api_token` generator. :::warning Existing `description` values are **not** carried over to `name`. If you ran the v8.2.14 generator and want to keep the descriptions, copy them across in the generated migration before `remove_column` runs, for example: ```ruby execute "UPDATE api_tokens SET name = description WHERE name IS NULL" ``` ::: 2. Pass a `name` wherever your application creates `ApiToken` records directly. `ApiToken` validates `name` presence (and a 100-character limit) for every token type, so `ApiToken.create(...)` calls that worked before now fail validation without one. The admin form already requires a name. ### Checklist 1. Run `rails generate cm_admin:add_name_to_api_token && rails db:migrate` (skip `add_description_to_api_token` — it has been removed). 2. Set a `name` in any code that creates `ApiToken` records directly. --- ## v8.3.3 This release adds per-recipient email open and click tracking to the Postmark webhook pipeline and the Email Log UI. Webhook events are now linked to the recipient they belong to, Click events record the clicked URL, and the Email Log show page surfaces per-recipient open counts and click details. ### Required Migration (Existing Installs) #### `cm_webhook_events` Gains Recipient and Click Link Columns `CmWebhookEvent` now carries two new pieces of data, both populated at webhook receipt: | Column | Type | Purpose | | --------------- | ------------------- | -------------------------------------------------------------------------------------------------------------- | | `recipient` | polymorphic + index | The recipient the event belongs to (matched by `MessageID` + payload recipient email against the log's record) | | `original_link` | string | For Click events, the clicked URL — denormalized at receipt so clicks can be grouped and counted in SQL | Events for CC/BCC recipients of the same message stay unlinked (`recipient: nil`) so they are never attributed to the To recipient. ##### Migration Steps 1. Run the [Add Webhook Event Recipient generator](../Generators.md#21-add-webhook-event-recipient-generator), then migrate: ```bash rails generate cm_admin:add_webhook_event_recipient rails db:migrate ``` The generated migration: - Adds `recipient_type` (string), `recipient_id` (bigint), and a composite index on both — each step is skipped if it already exists. - Adds `original_link` (string) — skipped if the column already exists. - Is idempotent (`column_exists?` / `index_exists?` guards) and safe to re-run. :::note Fresh installs don't need the extra generator — the `create_cm_webhook_events` template now includes the new columns. ::: :::warning Recipient attribution happens when a webhook is received. Historical events are **not** backfilled — they keep `recipient: nil`, and repeat-open history only exists from the upgrade onward (Postmark only retains the first open server-side). ::: ### New Features #### `clicked` Recipient Status A new `clicked` status sits after `opened` in the recipient status flow and renders with a purple badge: | From | Allowed transitions | | ----------- | ------------------- | | `delivered` | `opened`, `clicked` | | `opened` | `clicked` | | `clicked` | (terminal) | `opened` and `clicked` are both treated as open-equivalent statuses (`CmEmailLog::OPEN_EQUIVALENT_STATUSES`) so click-stage recipients still count as engaged. #### Email Log Show Page Additions - **Recipients table** now shows **First Opened At**, **Total Opens**, and **Link Clicks** per recipient, built from the log's webhook events via `recipients_with_engagement`. - **Click Details** section (shown when the log has clicks): one row per clicked link with its click count and click rate, sorted by click count. - **Events** tab: the log's webhook events with recipient, event type, created/processed timestamps, and error message. The `CmWebhookEvent` index page also gains a **Recipient** column. ### Internal Changes #### `status_helper_icon` Renamed to `cm_info_tooltip` The badge tooltip helper in `CmAdmin::ViewHelpers::FieldDisplayHelper` was renamed and now sets `bs_container: 'body'` so tooltips render correctly inside scrollable containers. No action is needed unless your application called `status_helper_icon` directly. ### Checklist 1. Run `rails generate cm_admin:add_webhook_event_recipient && rails db:migrate` (existing installs only — fresh installs get the columns from `create_cm_webhook_events`). --- ## v8.4.0 This release adds **Catalyst AI (CT Bot)** — an opt-in chat widget that answers admin users' questions via read-only MCP tools — plus a fix to the MCP `aggregate` tool schema for models without numeric columns. ### New Features #### Catalyst AI (CT Bot) A floating chat widget is available on every admin page for users whose `CmRole` has **Catalyst AI** enabled. Conversations stream replies over ActionCable, support attachments and clarifying-question cards, and record per-request token/cost usage as `CtCostTracking`. See the [Catalyst AI feature guide](../3.%20Features/CatalystAi.md) for details. ##### Migration Steps 1. Run the [Add Bot Conversations generator](../Generators.md#22-add-bot-conversations-generator), then migrate: ```bash rails generate cm_admin:add_bot_conversations rails db:migrate ``` The generated migrations create four tables — `ct_bot_conversations`, `ct_bot_messages`, `ct_bot_artifacts`, `ct_cost_trackings` — and add `is_catalyst_ai_enabled` (boolean, default `false`) to `cm_roles`. :::warning The conversations migration adds a foreign key to `users`. If your user class maps to a different table (e.g. `admins`), edit the generated migration's `foreign_key:` target before running `db:migrate`. ::: 2. Register the new models in `config/initializers/zcm_admin.rb`: ```ruby config.included_models = [..., CtBotConversation, CtBotMessage, CtCostTracking] ``` 3. Add your OpenRouter key to credentials (`rails credentials:edit`): ```yaml catalyst_ai: openrouter_api_key: sk-or-... ``` 4. Ensure `config/cable.yml` exists — the assistant streams replies over ActionCable. Use the Redis adapter in production. 5. Seed the Catalyst AI platform settings (`CmPlatformSetting.add_cm_default_settings`) or create **CT Bot Greeting**, **CT Bot Model**, and **CT Bot Queue** via the Platform Settings page. 6. Switch on **Catalyst AI** on each `CmRole` that should see the widget, and declare `mcp_actions` on the models the assistant may query. ### Checklist 1. Run `rails generate cm_admin:add_bot_conversations && rails db:migrate` (adjust the user-table foreign key if needed). 2. Add `CtBotConversation`, `CtBotMessage`, `CtCostTracking` to `config.included_models`. 3. Set the `catalyst_ai.openrouter_api_key` credential. 4. Confirm `config/cable.yml` is configured (Redis in production) and Sidekiq is running. 5. Seed/create the three Catalyst AI platform settings; restart the app and Sidekiq after changing them. 6. Enable **Catalyst AI** on the relevant roles and add `mcp_actions` to assistant-visible models. --- ## v8.4.1 This release adds **voice input** to the Catalyst AI (CT Bot) chat widget. Recordings are uploaded as `CtBotTranscription` records, transcribed in the background, and the text is streamed back to the widget over ActionCable. Transcription cost is recorded as `CtCostTracking`, like chat replies. ### Required Migration #### New `ct_bot_transcriptions` Table Voice recordings and their transcribed text are stored in a new `ct_bot_transcriptions` table: | Column | Type | Purpose | | ------------------------ | ----------------------- | -------------------------------------------------------------- | | `user_id` | reference (FK, not null) | The user who recorded the audio | | `ct_bot_conversation_id` | reference (FK) | The open conversation at recording time, if any | | `ct_bot_message_id` | reference (FK) | The message the transcription was sent with, once linked | | `request_id` | string | Client request ID used to route the result to the right widget | | `status` | integer (enum) | `pending`, `success`, `failed` | | `text` | text | The transcribed text | | `error_message` | string | Failure reason, if any | | `total_cost_cents` | decimal | Rolled-up cost of the transcription | | `completed_at` | datetime | When the transcription finished (success or failure) | The recorded audio is stored as an Active Storage attachment (`audio`). ##### Migration Steps 1. Run the [Add Bot Transcriptions generator](../Generators.md#24-add-bot-transcriptions-generator), then migrate: ```bash rails generate cm_admin:add_bot_transcriptions rails db:migrate ``` :::note This applies to every install using Catalyst AI — including fresh installs, which should run `add_bot_conversations` first and then `add_bot_transcriptions`. ::: :::warning The migration adds a foreign key to `users`. If your user class maps to a different table (e.g. `admins`), edit the generated migration's `foreign_key:` target before running `db:migrate`. ::: 2. Register the new model in `config/initializers/zcm_admin.rb`: ```ruby config.included_models = [..., CtBotConversation, CtBotMessage, CtBotTranscription, CtCostTracking] ``` ### New Features #### Voice Input in the Chat Widget - A microphone button in the chat widget records audio and uploads it to `CmAdmin::CtBotTranscriptionsController`. - `CtBotTranscriptionJob` transcribes the recording, stores the text, status and cost, then broadcasts the result over the signed `CmAdmin::CtBotTranscriptionChannel` stream. - When a message is sent in the same conversation, the transcription is linked to the resulting `CtBotMessage`. - A **Catalyst AI Transcriptions** index/show page lists each recording with its audio, text, status, Total Cost, and a Cost Trackings tab. #### New Platform Setting: CT Bot Transcription Model A new **CT Bot Transcription Model** platform setting (category **Catalyst AI**) selects the OpenRouter model used for transcription. Leave it blank to use the default (`openai/gpt-transcribe`). Seed it with `CmPlatformSetting.add_cm_default_settings` or create it via the Platform Settings page, and restart the app and Sidekiq after changing it. ### Checklist 1. Run `rails generate cm_admin:add_bot_transcriptions && rails db:migrate` (adjust the user-table foreign key if needed). 2. Add `CtBotTranscription` to `config.included_models`. 3. Seed/create the **CT Bot Transcription Model** platform setting (optional); restart the app and Sidekiq after changing it. --- ## v9.0.0 :::warning This release contains breaking changes. Work through the [Checklist](#checklist) before upgrading. ::: Settings are now split into two clearly separate kinds that never override each other: - **Project settings**: set by developers in `config/application.rb` (`config.x.project_settings`): project name, slug, logo URL, favicon URL and default email. Read them with `Rails.configuration.x.project_settings.*`. - **Platform settings**: edited by admins on the Platform Settings page and stored in `cm_platform_settings`. Read them with `CmAdmin::PlatformSettings`, which is backed by `Rails.cache` and expired whenever a setting is created, updated or deleted, so changes apply straight away in every process, with no restart. ### What changed | Before | Now | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `PLATFORM_SETTINGS["slug"]`: a frozen hash loaded at boot; changes needed a restart of the app and Sidekiq | `CmAdmin::PlatformSettings["slug"]`, cached and expired on save | | File settings returned a lambda that had to be called to get the URL | File settings return an Active Storage redirect path string | | **Project Name**, **Project Slug**, **Logo URL**, **Favicon URL** and **Default Email** platform settings overwrote `config.x.project_settings`, but only in the process that saved them | `config.x.project_settings` is only ever set from `config/application.rb` | | Those five settings were seeded by `CmPlatformSetting.add_cm_default_settings` | They are no longer seeded | See [Platform Settings](../3.%20Features/PlatformSetting.md#project-settings-vs-platform-settings) for the full API. ### Required changes #### 1. Move project values into `config/application.rb` If admins changed **Project Name**, **Project Slug**, **Logo URL**, **Favicon URL** or **Default Email** on the Platform Settings page, those values no longer apply. Copy them into `config/application.rb`: ```ruby config.x.project_settings.name = "Acme" config.x.project_settings.slug = "acme" config.x.project_settings.logo_url = "https://cdn.example.com/logo.png" config.x.project_settings.favicon_url = "https://cdn.example.com/favicon.png" config.x.project_settings.default_from_email = "no-reply@example.com" ``` Then delete the old rows so they don't confuse admins: ```ruby CmPlatformSetting.where(slug: %w[project-name project-slug logo-url favicon-url default-email]).destroy_all ``` #### 2. Replace `PLATFORM_SETTINGS` (recommended) `PLATFORM_SETTINGS` still works: `PLATFORM_SETTINGS["slug"]` is forwarded to `CmAdmin::PlatformSettings["slug"]`, so it finds the setting by slug and reads the same cached value. It is deprecated, though, and reports a `cm-admin` deprecation warning through `Rails.application.deprecators` each time it is used. In development every `cm-admin` deprecation raises `ActiveSupport::DeprecationException`, so remaining uses are caught before they ship; in staging and production it keeps working. Compared with the old frozen hash there are three differences: - File settings return a path string instead of a lambda, so code like `PLATFORM_SETTINGS["login-image"].call` must drop the `.call`. - A slug with no matching setting raises `CmAdmin::PlatformSettings::NotFound` instead of returning `nil`. Where the code has a fallback, use `fetch` with a default. - Only `[]` and `fetch` are supported. Other Hash methods such as `dig`, `each`, `keys` and `to_h` raise `NoMethodError`. ```ruby # Before PLATFORM_SETTINGS["ct-bot-model"].presence || "openai/gpt-5.6-sol" # After CmAdmin::PlatformSettings.fetch("ct-bot-model", "openai/gpt-5.6-sol") ``` #### Removed - `CmPlatformSetting.sync_project_settings_to_config` - `CmPlatformSetting::PROJECT_SETTINGS_SLUG_MAP` ### Checklist 1. Copy any admin-edited project values into `config/application.rb`, then delete those five platform setting rows. 2. Replace `PLATFORM_SETTINGS[...]` with `CmAdmin::PlatformSettings[...]` (or `.fetch(slug, default)` where a missing setting is expected), and drop any `.call` on file settings. 3. Remove any calls to `CmPlatformSetting.sync_project_settings_to_config`. --- ## 6.UI UI components and styles available in cm-admin explained here. --- ## Buttons ### Overview CmAdmin provides a set of custom button classes built on top of Bootstrap. These custom classes ensure consistent styling across the admin panel while leveraging Bootstrap's button foundation. :::warning Important **Do not use Bootstrap button classes directly** (e.g., `btn-outline-primary`, `btn-outline-dark`). Always use CmAdmin's custom button classes listed below to maintain design consistency. ::: ### Available Button Classes | Class | Description | Based On | | ----------------- | --------------------------------------------------- | --------------------------- | | `btn-cta` | Primary call-to-action button with solid background | Primary color filled | | `btn-primary` | Primary outlined button | `btn-outline-primary` | | `btn-secondary` | Secondary outlined button | `btn-outline-dark` | | `btn-ghost` | Light/subtle button | `btn-light` | | `btn-transparent` | Fully transparent button | Transparent background | | `btn-danger` | Danger/destructive action button | `btn-outline-danger` | | `btn-link` | Link-styled button with underline | `btn-link` | | `btn-dark` | Dark outlined button | `btn-outline-dark` | | `btn-loading` | Disabled state for loading actions | Primary with disabled state | #### Preview ![Button Classes Demo](/img/buttons-demo.png) ### Usage #### Basic Usage ```html ``` #### With Icons All button classes include `btn-icon-spacing` which provides proper spacing when using icons alongside text: ```html ``` ### Button Class Details #### `btn-cta` The main call-to-action button. Use for primary actions like "Save", "Submit", or "Create". - **Appearance:** Solid primary color background with white text - **Hover:** Slightly darker shade #### `btn-primary` An outlined version of the primary button. Use for secondary emphasis actions. - **Appearance:** Primary color outline with primary color text #### `btn-secondary` A neutral outlined button. Use for less prominent actions. - **Appearance:** Dark outline with dark text #### `btn-ghost` A subtle, light button. Use for tertiary actions or within cards. - **Appearance:** Light gray background #### `btn-transparent` A completely transparent button. Use for icon-only buttons or minimal UI. - **Appearance:** No background or border #### `btn-danger` For destructive actions like delete or remove. - **Appearance:** Red outline with red text #### `btn-link` A button styled as a link with underline. - **Appearance:** Link color with underline #### `btn-loading` Use when an action is in progress. Automatically includes disabled state. - **Appearance:** Disabled primary button ### Best Practices 1. **Use `btn-cta`** for the most important action on a page 2. **Use `btn-primary`** for secondary important actions 3. **Use `btn-secondary`** for cancel or back actions 4. **Use `btn-danger`** only for destructive actions 5. **Use `btn-ghost` or `btn-transparent`** for less prominent actions ### Why Custom Classes? CmAdmin's button classes: - Ensure consistent sizing with `btn-sm` applied by default - Include icon spacing utilities automatically - Use the application's primary color variables - Provide a unified look across all admin pages --- ## UI Introduction cm-admin uses **Bootstrap 5** as its CSS foundation, wrapped inside a `.cm-admin` root class to ensure style isolation and prevent conflicts with the host application's styles. **Design System:** [CM-Admin Design System (Figma)](https://www.figma.com/design/YJpnOd589W7vQRnrgbFqFJ/CM-Admin---Design-System?node-id=7107-38569&t=Oi3PCol1zOd7br9t-0) --- ### Core Concept: Scoped Bootstrap All cm-admin styles are scoped under the `.cm-admin` class. When creating custom components, we use SCSS `@extend` to inherit Bootstrap classes while keeping them namespaced: ```scss // Don't use Bootstrap classes directly in markup // Use cm-admin scoped classes ``` #### How It Works ```scss @import "../helpers/index.scss"; // Custom button extending Bootstrap classes .cm-admin .btn-cta { @extend .btn, .btn-sm; --bs-btn-color: var(--bs-white); --bs-btn-bg: var(--bs-primary); } .cm-admin .btn-primary { @extend .btn, .btn-outline-primary, .btn-sm; } ``` This pattern: - **Isolates styles** - cm-admin styles won't leak into the host app - **Leverages Bootstrap** - Full access to Bootstrap's utility classes and components - **Enables customization** - Override Bootstrap variables and add custom properties --- ### File Structure ``` app/assets/stylesheets/cm_admin/ ├── helpers/ │ ├── _variable.scss # Color, typography, spacing variables │ ├── _mixins.scss # Reusable mixins (flex, font, etc.) │ ├── _typography.scss # Typography utilities and classes │ └── index.scss # Exports all helpers ├── components/ │ ├── _buttons.scss │ ├── _alerts.scss │ ├── _modal.scss │ └── ... ├── base/ # Page-specific styles └── dependency/ └── bootstrap/ # Bootstrap source files ``` --- ## Mixins ### `flex` Mixin Quickly create flexbox layouts: ```scss @mixin flex($direction: row, $justify: normal, $align: normal, $wrap: nowrap) // Usage .my-container { @include flex($justify: center, $align: center); } .my-column { @include flex($direction: column, $justify: space-between); } ``` --- ### `font` Mixin Apply font styles consistently: ```scss @mixin font($size: $t3-text, $color: $primary-text-clr, $weight: normal) // Usage .my-label { @include font($size: $t4-text, $color: $ink-lighter-clr, $weight: bold); } ``` --- ### `typography` Mixin Enhanced typography with more options: ```scss @mixin typography( $size: 14, $weight: "regular", $color: $primary-text-clr, $line-height: 1.5, $family: $primary-font ) // Usage .field-label { @include typography(14, "bold", $primary-text-clr); } .section-heading { @include typography(16, "bold", $primary-text-clr); } ``` --- ### `text-truncate` Mixin Truncate text with ellipsis: ```scss @mixin text-truncate($lines: 1) // Single line truncation .truncate-single { @include text-truncate(1); } // Multi-line truncation .truncate-multi { @include text-truncate(3); } ``` --- ### `transition-linear` Mixin Standard transition for hover effects: ```scss @mixin transition-linear // Usage .my-button { @include transition-linear; &:hover { background: $brand-hover-color; } } ``` --- ### Utility Classes #### Font Size Classes ```scss .fs-12, .f12 // 12px .fs-14, .f14 // 14px .fs-16, .f16 // 16px .fs-18, .f18 // 18px .fs-20, .f20 // 20px .fs-24, .f24 // 24px ``` #### Font Weight Classes ```scss .fw-regular // 400 .fw-medium // 500 .fw-bold // 600 .fw-bolder // 900 .bold // 600 (legacy) .bolder // 900 (legacy) ``` #### Line Height Classes ```scss .lh-tight // 1.2 .lh-normal // 1.5 .lh-relaxed // 1.75 .lh-loose // 2 ``` #### Text Truncation Classes ```scss .text-truncate // Single line .text-truncate-2 // 2 lines .text-truncate-3 // 3 lines ``` --- ### Creating New Components When adding new components, follow this pattern: ```scss // 1. Import helpers @import "../helpers/index.scss"; // 2. Scope under .cm-admin .cm-admin .my-component { // 3. Extend Bootstrap classes @extend .d-flex, .align-items-center; // 4. Use cm-admin variables background: $grey-lightest-clr; border-radius: $radius-4; // 5. Use mixins for common patterns @include font($size: $t4-text, $color: $primary-text-clr); // 6. Add custom styles padding: 16px; &:hover { @include transition-linear; background: $grey-lighter-clr; } } ``` #### Example: Custom Alert ```scss @import "../helpers/index"; .cm-admin .alert { @extend .alert-dismissible, .d-flex, .align-items-center; } .flag-alert { max-width: 752px; padding: 24px; background: $red-lightest-clr; border-radius: $radius-4; .alert-header { @include font($size: $t6-text, $color: $red-regular-clr, $weight: bold); text-transform: uppercase; } .alert-body { margin-top: 16px; .body-title { @include font($size: $t4-text, $color: $primary-text-clr, $weight: bold); } } } ``` --- ### Best Practices 1. **Always scope under `.cm-admin`** - Prevents style leakage 2. **Use `@extend` for Bootstrap classes** - Keeps markup clean 3. **Prefer variables over hardcoded values** - Ensures consistency 4. **Use mixins for repeated patterns** - Reduces code duplication 5. **Follow the color scale** - Use semantic colors for states (error, success, etc.) --- ## Variables :::warning Important Always use variables in the codebase. If a color is missing from the variables, find an appropriate name and add it to `_variable.scss` before using it. **Never use hex codes directly in SCSS files.** ::: Import variables in your SCSS files: ```scss @import "../helpers/index.scss"; ``` --- ### Color Variables | Variable | Value | Usage | | -------------------- | --------- | ------------------- | | `$brand-color` | `#6554e0` | Primary brand color | | `$brand-hover-color` | `#e6e4fa` | Brand hover state | | `$sidebar-bg-color` | `#252525` | Sidebar background | #### Semantic Colors | Variable | Value | Usage | | ------------------ | --------- | --------------- | | `$informative-clr` | `#2f80ed` | Info states | | `$error-clr` | `#f83636` | Error states | | `$warning-clr` | `#ffc845` | Warning states | | `$positive-clr` | `#30c39e` | Success states | | `$disabled-clr` | `#9ca7ae` | Disabled states | #### Color Scales Each semantic color has three variants: ```scss // Blue $blue-regular-clr: #2f80ed; $blue-light-clr: #cde1fb; $blue-lightest-clr: #eef5fe; // Red $red-regular-clr: #f83636; $red-light-clr: #fdcfcf; $red-lightest-clr: #feefef; // Yellow $yellow-regular-clr: #ffc845; $yellow-light-clr: #ffefc7; $yellow-lightest-clr: #fff8e9; // Green $green-regular-clr: #30c39e; $green-light-clr: #c1ede2; $green-lightest-clr: #eefaf7; // Grey $grey-regular-clr: #c7ced5; $grey-light-clr: #d9dee3; $grey-lighter-clr: #f3f4f6; $grey-lightest-clr: #f8f9fa; ``` #### Ink (Text) Colors ```scss $ink-regular-clr: #1d2129; // Primary text $ink-light-clr: #3b4352; // Secondary text $ink-lighter-clr: #6b7586; // Muted text $ink-lightest-clr: #9ca7ae; // Placeholder text $primary-text-clr: #1d2129; ``` --- ### Typography Variables ```scss // Font family $primary-font: "Open Sans", sans-serif; // Font sizes $t1-text: 20px; $t2-text: 18px; $t3-text: 16px; $t4-text: 14px; $t5-text: 13px; $t6-text: 12px; // Font weights (use with font-weight() function) $font-weight: ( "regular": 400, "medium": 500, "bold": 600, "bolder": 900, ); ``` --- ### Border Radius Variables ```scss $radius-0: 0; $radius-1: 1px; $radius-2: 2px; $radius-3: 3px; $radius-4: 4px; $radius-5: 5px; $radius-6: 6px; $radius-8: 8px; $circle: 50%; ``` --- ## Windsurf Setup ### Steps to set up Windsurf for a project ```bash git rm -rf .windsurf/ brew tap commutatus/tools https://github.com/commutatus/homebrew-tools brew install cm-tools cd [repo-path] rm .github/workflows/sync-windsurf-config.yml cm-tools windsurf-sync -p rails ``` Add changes, commit, and raise a PR. --- ## AWS Documentation coming soon. --- ## AssociatedRecordHistory ### Description CM Admin supports tracking changes to associated models by specifying them in `paper_trail_associations`. This is useful when you want to show a single history timeline that includes the parent record and its related models. ### Usage Add `CmAdmin::PaperTrailAssociations` to your host app's `ApplicationRecord`: ```ruby class ApplicationRecord < ActiveRecord::Base include CmAdmin::PaperTrailAssociations primary_abstract_class end ``` In the model that needs associated history, call `paper_trail_associations` with the associations to include: ```ruby has_paper_trail paper_trail_associations :po_items, :po_parts, :po_pieces, :release_orders, :ro_pieces, :attachments ``` Note: - `has_paper_trail` must be added to the model as well. - In the example above, the parent model has `has_many :po_items`, `PoItem` has `has_many :po_parts`, and so on. Example associations: ```ruby has_many :po_items, inverse_of: :production_order, dependent: :destroy has_many :po_parts, through: :po_items has_many :po_pieces, through: :po_parts has_many :release_orders, dependent: :destroy has_many :ro_pieces, through: :release_orders has_many :attachments, as: :attachable, dependent: :destroy ``` --- ## Catalyst Setup This guide covers how to set up the `cm-admin` gem (internally referred to as Catalyst) locally and connect it to another Rails application for development and testing. ### Overview `cm-admin` is a Ruby on Rails admin panel gem. This repository contains both the gem source and a `feature-app/` Rails application that can be used to test changes. When developing new features or fixing bugs, you typically want to point a host Rails app at your local copy of `cm-admin` instead of the published gem. ### Prerequisites - Ruby 3.3.0 or higher - Bundler - A supported Rails application (the `feature-app/` in this repo or another project) - PostgreSQL (if running the `feature-app/`) - Node.js (if building the documentation site) ### Local Setup #### 1. Clone the repository ```bash git clone git@github.com:commutatus/cm-admin.git cd cm-admin ``` #### 2. Install gem dependencies ```bash bundle install ``` This installs the dependencies declared in the root `Gemfile`. #### 3. Run the feature app The `feature-app/` directory is a Rails application configured to load `cm-admin` from the local path. ```bash cd feature-app bundle install bin/rails db:prepare bin/rails server ``` Visit `http://localhost:3000` to see the admin panel in action. ### Connect to Another Local App To test `cm-admin` changes in a different Rails project, point that project's Gemfile to your local checkout. #### 1. Update the host app's Gemfile Open the host app's `Gemfile` and change the `cm-admin` line to use a local path: ```ruby gem 'cm-admin', path: '../cm-admin' ``` Make sure the relative path matches your directory layout. For example, if `cm-admin` and the host app are siblings: ``` workspace/ ├── cm-admin/ └── my-rails-app/ ``` Then the path from `my-rails-app` is `../cm-admin`. #### 2. Install dependencies ```bash cd my-rails-app bundle install ``` Bundler will now resolve `cm-admin` from the local directory instead of RubyGems or GitHub. #### 3. Restart the server ```bash bin/rails server ``` Any code changes you make in `cm-admin/` are reflected in the host app after a server restart. ### Verify Changes When making changes to the gem: 1. Edit the source files in the root `cm-admin` directory. 2. Restart the host app or `feature-app` server. 3. Reload the page in your browser. If the change affects assets, styles, or JavaScript, you may also need to clear the host app's tmp cache: ```bash bin/rails tmp:clear ``` ### Troubleshooting - **Bundle cannot find the local gem**: Double-check the relative `path:` in the host app's Gemfile. - **Changes not appearing**: Restart the Rails server. The gem is loaded at boot time, not on every request. - **Database errors in `feature-app`**: Run `bin/rails db:prepare` or `bin/rails db:create db:migrate` before starting the server. - **Sprockets or asset errors**: Make sure the host app includes the required `cm-admin` assets in its manifests. ### Related - [CM Admin Introduction](/) - [CM Admin Starter Pack](https://github.com/commutatus/cm-admin-panel-demo) — example Rails app using `cm-admin` --- ## Catalyst v8.0.12 ### v8.0.12 (2026-09-25) **Release date:** 2026-09-26 > ✅ No breaking changes and no database migrations or rake tasks are required for this release — it is a drop-in upgrade from v8.0.11. ### 🚀 Features - ✨ **Year-select dropdown for all Flatpickr date pickers** — Every Flatpickr date picker across the admin panel now includes a year-select dropdown, letting users jump straight to a specific year instead of clicking through months one at a time. The dropdown is opt-out per field (e.g. birth dates that need free-form year entry can keep Flatpickr's native year navigation), and inputs support optional `data-min-date` / `data-max-date` attributes (ISO `YYYY-MM-DD`) for per-picker date ranges. Filter pickers initialize per element so each input's own `data-*` attributes are honored. [#1576](https://github.com/commutatus/cm-admin/pull/1576) ([@austinjb32](https://github.com/austinjb32)) ![Year-select dropdown open in a Catalyst date picker](https://github.com/user-attachments/assets/04b5bb97-e952-4e51-8d6a-7044150d5467) ![Year-select dropdown shown across multiple date pickers](https://github.com/user-attachments/assets/44d297a1-4208-4477-8d51-a9322169e328) ### 🐛 Bug Fixes - 🐛 **CSV imports no longer fail on non-UTF-8 files** — Imported CSVs that arrive in other encodings (e.g. Windows-1252) or contain BOM characters previously caused header and delimiter parsing errors. Incoming CSV content is now normalized to clean UTF-8 and BOM characters are stripped before parsing. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - 🐛 **Failed imports no longer get stuck in `in_progress`** — `FileImportProcessorJob#perform` now has a top-level `rescue StandardError` that marks the import as `failed`, sets `completed_at`, and notifies Airbrake, so unhandled exceptions no longer leave file imports hanging indefinitely. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - 🐛 **Import error reporting format aligned** — The rescue block's `invalid_row_items` payload now uses the same array-of-arrays structure produced by `run_importer`, so failed imports report invalid rows consistently with successful importer output. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### ✨ Improvements - ♻️ **Simpler CSV encoding and delimiter detection** — Dynamic `csv_encoding` detection was replaced with a `BaseImporter::CSV_ENCODING` constant plus a `normalize_to_utf8` helper, and files are always read as UTF-8. This removes a source of parsing ambiguity and makes import behavior predictable. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - 🛡️ **Broader failure visibility for imports** — Unexpected failures during import processing and the completion-email step are now caught and surfaced through Airbrake. Note: the `rescue StandardError` is intentionally broad; if `Airbrake.notify` itself raises, the status update may be skipped. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### 📚 Documentation - 📚 **Changelog updated for v8.0.11** — Added the v8.0.11 entry (including the Flatpickr year-select work from [#1576](https://github.com/commutatus/cm-admin/pull/1576) ([@austinjb32](https://github.com/austinjb32))) to `docs-site/docs/Changelog/2026-09.md` and refreshed `docs-site/static/releases.json` so the docs site and release feed stay in sync. ([52dacecd3](https://github.com/commutatus/cm-admin/commit/52dacecd3)) ### v8.0.11 (2026-09-25) - Merge pull request #1576 from commutatus/CT-317 (d94eeb984) - refactor(flatpickr): use date-fns for year bounds and px padding (079a66630) - fix(flatpickr): pass filter elements to yearSelectOptions for per-element data attributes (91a8ba034) - Merge branch 'main' of github.com:commutatus/cm-admin into CT-317 (9c2934114) - refactor(flatpickr): normalise data-min-date/data-max-date to dates (0b1fc4106) - fix(demo-forms): use a dedicated renewal_date column for the default year selector demo (7e41ae8b0) - fix(flatpickr): restore native year input for default year selector pickers (55a911cd4) - feat(flatpickr): add option to use default year selector via data attribute (78bf270de) - fix(flatpickr): add max-height to year-select dropdown (e0cf23814) - refactor(flatpickr): restore minDate/maxDate defaults with data-attribute overrides (e8f7ac59c) - fix(flatpickr): improve year-select plugin integration robustness (0698a056f) ### v8.0.10 (2026-09-24) ## Catalyst v8.0.10 **Release date:** 2026-10-01 **Gem:** `cm_admin` v8.0.10 **Compare:** [`v8.0.9...v8.0.10`](https://github.com/commutatus/cm-admin/compare/v8.0.9...v8.0.10) > ✅ **No breaking changes. No migrations required.** This release is a safe drop-in upgrade — no rake tasks, no database migrations, and no API changes to run. The bulk of the work hardens the release-automation pipeline so generated changelogs reliably reach `main`. --- ### 🚀 Features - 🤖 **AI-powered changelog generation and Slack notifications for releases** — every gem release now automatically assembles release notes from `CHANGELOG.md`, the git log, merged PR bodies, and the docs diff via the OpenRouter API, publishes them to a monthly Docusaurus changelog page, and posts a rich Block Kit summary to Slack. No manual changelog writing required. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🔗 **PR links and embedded media in generated release notes** — each generated change entry now links back to its source PR, and GitHub attachment URLs found in PR bodies are preserved and embedded as inline images rather than being dropped. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📊 **Release registry with breaking-change and migration tracking** — a new `releases.json` registry records the version, date, and whether a release carries breaking changes or migrations, so downstream tooling can surface upgrade warnings programmatically. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🌐 **`releases.json` served as a static file via Docusaurus** — the registry ships with the docs site and is available at a stable public URL. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - ⚠️ **Warning badges and migration commands in generated release notes** — release notes now open with a prominent Breaking Changes / Migration Required badge and include the exact rake or migration commands to run when one applies. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) ### 🐛 Bug Fixes - 🔁 **Changelog commits no longer silently dropped on non-fast-forward push rejections** — the docs push step now rebases on `origin/main` and retries up to 5 times with a 5s backoff. Previously a single `git push origin main` died whenever `main` advanced between checkout and push, so the September 2026 page and `releases.json` never landed on main. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - ♻️ **Docs-page updates are now idempotent** — job re-runs no longer duplicate a version's release section, and legacy `./` index links are migrated in place so re-runs can't insert duplicate entries. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - 🔗 **Changelog index links corrected to `./Changelog/`** — the previous relative links (`./2026-09`) resolved to `/docs/2026-09`, a missing page, because the index is served at `/docs/Changelog` without a trailing slash. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - 🏷️ **Release registry versions stored without the leading `v`** — versions are now stored in a numerically comparable format, and dedup handles both `v8.0.9` and `8.0.9` so re-runs can't create duplicate entries. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - 🌐 **`docs_url` now points at the live domain** — registry and Slack links now use `docs.cm-admin.commutatus.com`; the previously used `cm-admin-docs.commutatus.com` has no DNS records and would 404 for users. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - 📦 **Pipeline-generated v8.0.9 artifacts added** — the September 2026 release-notes page and its `releases.json` registry entry are now committed, backfilling the release that the broken push step had dropped. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - 🪶 **Release registry simplified** to track only version, date, breaking-change/migration flags, and docs URL, dropping redundant metadata. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🧩 **`response_format` extracted into a dedicated method** in the release-notes generator for readability. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📐 **JSON schema moved into OpenRouter's `response_format`** and the prompt template relocated to `.github/prompts/prompt_template.txt`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📏 **`max_tokens` increased to 4000 to prevent JSON truncation** — the model response was occasionally cut off mid-JSON, which caused changelog generation to silently fall back to raw git-log output. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📦 **Inline workflow scripts extracted out of YAML** into `.github/scripts` and then relocated to `.github/lib`, making the workflows far easier to read and maintain. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🧪 **Release creation and push skipped during test runs; `escape_md` cleaned up** — dry runs no longer create tags or push commits to `main`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🔧 **`workflow_dispatch` and `pr_number` test inputs removed** from the changelog workflow now that the pipeline is stable. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🗑️ **Obsolete `notify_slack_release.sh` script removed** — superseded by `notify_slack_release.rb`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) ### ⚠️ Breaking Changes None in this release. ✅ ### 📚 Documentation - 📘 **`ReleaseChangelog.md` examples updated** to match the new registry format and live domain — the sample `releases.json` entry now shows `"version": "7.2.26"` (no leading `v`) and `"docs_url": "https://docs.cm-admin.commutatus.com/docs/Changelog/2026-09"`, so anyone following the docs copies a working configuration. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - 📑 **Changelog index link corrected** in `docs-site/docs/Changelog/index.md` — the September 2026 entry now points at `./Changelog/2026-09`, matching the served URL structure. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - 🗓️ **September 2026 release-notes page published** (`docs-site/docs/Changelog/2026-09.md`) — documents the v8.0.9 release, including its feature, bug-fix, and improvement sections. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32)) - 🚀 **"Releasing the Gem"** (`docs-site/docs/4. Deployment/ReleaseGem.md`) — covers the stable `release-cm-gem.yml` workflow (trigger, `bump_type` input, build/release/changelog job graph, required secrets and variables) and the beta `release-beta-gem.yml` workflow, including how beta version numbers are derived from existing `v.beta*` tags. Also notes that beta releases are pre-releases and do not trigger the changelog workflow. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🤖 **"Release Changelog & Notifications"** (`docs-site/docs/4. Deployment/ReleaseChangelog.md`) — documents the reusable `changelog-release.yml` workflow and each of its components: `collect_context.sh`, `generate_release_notes.rb` (including the JSON response shape and fallback behaviour when OpenRouter is unavailable), `update_docs_page.sh`, `update_release_registry.rb`, and `notify_slack_release.rb`. Also documents the prompt template location and how to tune generated note structure and tone. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📑 **Changelog section index** (`docs-site/docs/Changelog/index.md`) — a landing page for the Changelog docs section listing recent months, with each monthly page (`.md`) holding that month's AI-generated release notes. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) --- sidebar_position: 202609 --- ## Release Notes — September 2026 ### v8.0.9 (2026-09-24) ## Catalyst v8.0.9 **Release date:** September 24, 2026 **Gem:** `cm_admin` v8.0.9 **Compare:** [`v8.0.8...v8.0.9`](https://github.com/commutatus/cm-admin/compare/v8.0.8...v8.0.9) > ✅ **No breaking changes. No migrations required.** This release is a safe drop-in upgrade — no rake tasks, no database migrations, and no API changes to run. The bulk of the work is release-automation tooling plus two UI fixes. --- ### 🚀 Features - 🤖 **AI-powered changelog generation and Slack notifications for releases** — every gem release now automatically assembles release notes from `CHANGELOG.md`, the git log, merged PR bodies, and the docs diff via the OpenRouter API, publishes them to a monthly Docusaurus changelog page, and posts a rich Block Kit summary to Slack. No manual changelog writing required. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🔗 **PR links and embedded media in generated release notes** — each generated change entry now links back to its source PR, and GitHub attachment URLs found in PR bodies are preserved and embedded as inline images rather than being dropped. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📊 **Release registry with breaking-change and migration tracking** — a new `releases.json` registry records the version, date, and whether a release carries breaking changes or migrations, so downstream tooling can surface upgrade warnings programmatically. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🌐 **`releases.json` served as a static file via Docusaurus** — the registry ships with the docs site and is available at a stable public URL. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - ⚠️ **Warning badges and migration commands in generated release notes** — release notes now open with a prominent Breaking Changes / Migration Required badge and include the exact rake or migration commands to run when one applies. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) ### 🐛 Bug Fixes - 🔒 **Custom form modal errors now render in shared alert banner markup, safely** — `custom_form_submit_controller.js` renders submit errors using the standard `alert-banner` structure instead of ad-hoc `d-flex` markup, so modal errors match the alert styling used elsewhere in cm-admin (`_alert_banner.html.slim`, `_default_import_notes.html.slim`). The server-provided message is now assigned via `textContent` rather than interpolated into `innerHTML`, closing an XSS vector whenever a server error message reflects user input. `errorContainer` is also null-guarded on both the submit and error paths, removing a latent `TypeError` when the container is absent. New spacing styles for `ul`, `p`, and `li` inside `.alert-banner-text` keep list-based alert content readable. [#1614](https://github.com/commutatus/cm-admin/pull/1614) ([@BrijeshSajeev](https://github.com/BrijeshSajeev)) - 🏷️ **Catalyst branding in release notes and Slack notifications** — generated notes and Slack messages no longer leak internal project naming. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📏 **`max_tokens` increased to 4000 to prevent JSON truncation** — the model response was occasionally cut off mid-JSON, which caused changelog generation to silently fall back to raw git-log output. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🧪 **Release creation and push skipped during test runs; `escape_md` cleaned up** — dry runs no longer create tags or push commits to `main`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🧹 **Code review findings on the release automation addressed** — follow-up fixes from review of the changelog pipeline. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - 🎨 **Export modal secondary button styling moved to SCSS `@extend`** — the modal footer now carries a `[type="button"], .btn-secondary` rule extending `.btn, .btn-outline-dark, .btn-sm, .btn-icon-spacing`, mirroring the existing primary-action rule. This fixes the export modal's Close button rendering as Bootstrap's solid `btn-secondary` instead of the cm-admin design-system outline style, which happened because the export modal renders outside the `.cm-admin` wrapper and never picked up `.cm-admin .btn-secondary`. The export modal's class list was simplified from `btn btn-secondary` to `btn-secondary`. [#1613](https://github.com/commutatus/cm-admin/pull/1613) ([@BrijeshSajeev](https://github.com/BrijeshSajeev)) - 🧩 **`response_format` extracted into a dedicated method** in the release-notes generator for readability. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🪶 **Release registry simplified** to track only version, date, breaking-change/migration flags, and docs URL, dropping redundant metadata. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🔧 **`workflow_dispatch` and `pr_number` test inputs removed** from the changelog workflow now that the pipeline is stable. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📦 **Inline workflow scripts extracted out of YAML** into `.github/scripts` and then relocated to `.github/lib`, making the workflows far easier to read and maintain. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📐 **JSON schema moved into OpenRouter's `response_format` and the prompt template relocated** to `.github/prompts/prompt_template.txt`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 💅 **Hash shorthand used for `response_format`** and a redundant comment dropped. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🗑️ **Obsolete `notify_slack_release.sh` script removed** — superseded by `notify_slack_release.rb`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) ### ⚠️ Breaking Changes None in this release. ✅ ### 📚 Documentation Three new pages were added to the docs site (182 insertions across 3 files), documenting the release pipeline end to end: - 🚀 **"Releasing the Gem"** (`docs-site/docs/4. Deployment/ReleaseGem.md`) — covers the stable `release-cm-gem.yml` workflow (trigger, `bump_type` input, build/release/changelog job graph, required secrets and variables) and the beta `release-beta-gem.yml` workflow, including how beta version numbers are derived from existing `v.beta*` tags. Also notes that beta releases are pre-releases and do not trigger the changelog workflow. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 🤖 **"Release Changelog & Notifications"** (`docs-site/docs/4. Deployment/ReleaseChangelog.md`) — documents the reusable `changelog-release.yml` workflow and each of its components: `collect_context.sh`, `generate_release_notes.rb` (including the JSON response shape and fallback behaviour when OpenRouter is unavailable), `update_docs_page.sh`, `update_release_registry.rb`, and `notify_slack_release.rb`. Also documents the prompt template location and how to tune generated note structure and tone. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) - 📑 **Changelog section index** (`docs-site/docs/Changelog/index.md`) — a new landing page for the Changelog docs section listing recent months, with each monthly page (`.md`) holding that month's AI-generated release notes. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32)) --- ## Release Notes — October 2026 ### v8.4.2 (2026-10-30) ## Catalyst v8.4.2 — 2026-10-30 ### 🚀 Features _No new features in this release._ ### 🐛 Bug Fixes - 🐛 Removed the opacity transition from the chat widget’s copy button. [#1783](https://github.com/commutatus/cm-admin/pull/1783) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### ✨ Improvements - ✨ Updated OTP email rendering to use the shared branded template and styled code block used by other Catalyst email flows. [#1782](https://github.com/commutatus/cm-admin/pull/1782) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) ### ⚠️ Breaking Changes _No breaking changes._ ### 📚 Documentation - 📚 Updated the October 2026 changelog with the v8.4.1 release notes. [215a313d2](https://github.com/commutatus/cm-admin/commit/215a313d2) ### v8.4.1 (2026-10-30) ## Catalyst v8.4.1 — 2026-10-30 ⚠️ **WARNING: Migration Required** — Catalyst AI voice input adds the `ct_bot_transcriptions` table. Generate and run the migration: ```bash rails generate cm_admin:add_bot_transcriptions rails db:migrate ``` Fresh installs using Catalyst AI should first run `rails generate cm_admin:add_bot_conversations`, then the commands above. If your user model uses a table other than `users`, update the generated migration’s user foreign-key target before running `rails db:migrate`. Add `CtBotTranscription` to `config.included_models` after migrating. ### 🚀 Features - 🎙️ Added voice input to the Catalyst AI chat widget, with recording controls and inline recording, transcription, and failure states. Transcribed text is added to the composer. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 🗂️ Added Catalyst AI Transcriptions admin index and show pages, including audio playback and cost tracking; transcriptions can also be viewed from bot conversations and messages. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 📋 Added a **Copy as Markdown** action for chat messages. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### 🐛 Bug Fixes - 🔊 Normalized transcription audio formats and shared the ActionCable consumer to improve transcription delivery. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### ✨ Improvements - 💰 Added background transcription processing, with transcription status and costs recorded and costs rolled up to the conversation. A new **CT Bot Transcription Model** platform setting configures the transcription model. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 🧭 Made transcriptions visible in sidebar navigation and updated chat widget button tooltips and labels. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 🧪 Removed root-level RSpec setup, its dependency, and Rake tasks; tests now live in `feature-app/spec`. The README points developers to the feature app for running tests. [#1780](https://github.com/commutatus/cm-admin/pull/1780) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes _No breaking changes._ ### 📚 Documentation - 📚 Added the v8.4.1 upgrade guide and `add_bot_transcriptions` generator documentation. Updated the Catalyst AI guide with voice input setup, model settings, transcription behavior, and admin views. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 📝 Updated docs-site setup references to use npm instead of Yarn. [#1780](https://github.com/commutatus/cm-admin/pull/1780) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### v8.4.0 (2026-10-30) ## Catalyst v8.4.0 — 2026-10-30 ⚠️ **WARNING: Migration Required** — Catalyst AI adds tables for conversations, messages, question artifacts, and cost tracking, and adds an AI opt-in flag to `cm_roles`. Generate and run the migrations: ```bash rails generate cm_admin:add_bot_conversations rails db:migrate ``` If your user model uses a table other than `users`, update the generated conversation migration’s foreign-key target before running `rails db:migrate`. ### 🚀 Features - 🤖 Added Catalyst AI (CT Bot), an opt-in assistant available as a floating chat widget in the admin panel. It answers questions using read-only MCP tools scoped to the asking user’s permissions. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 💬 Added conversation history, live ActionCable reply streaming, file attachments, unread badges, and interactive clarifying-question cards. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 💰 Added per-request token and cost tracking, with totals available for messages and conversations. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### 🐛 Bug Fixes - 🧮 Fixed the MCP aggregate tool input schema so it omits the `column` field for models without numeric columns. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### ✨ Improvements - 📱 Improved the chat widget for mobile screens and added dynamic tooltips and clearer UI details. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - ⏱️ Added a completion timestamp for assistant messages to track when replies finish. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Added a Catalyst AI guide covering setup, configuration, permissions, security, and admin views; documented the new generator and added v8.4.0 upgrade steps. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### v8.3.12 (2026-10-29) ## Catalyst v8.3.12 — 2026-10-29 ### 🚀 Features - 🔑 Enabled history tracking for API Tokens, so the History tab shows an audit trail of record changes and actions. [#1760](https://github.com/commutatus/cm-admin/pull/1760) ([#anbublacky](https://github.com/anbublacky)) - 📝 Added BlogPost and BlogCategory admin models and specs to the feature app. [61e0de384](https://github.com/commutatus/cm-admin/commit/61e0de384) - 🏖️ Expanded the feature app’s leave-management example with leave requests, types, balances, compensatory credits, and holiday management. Leave records now belong directly to User, avoiding a separate Employee model. [#1752](https://github.com/commutatus/cm-admin/pull/1752) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) ### 🐛 Bug Fixes - 🧭 Normalized breadcrumb-chain parameters when building new associated-record URLs, preventing malformed breadcrumb chains on subsequent pages. [#1768](https://github.com/commutatus/cm-admin/pull/1768) ([#anbublacky](https://github.com/anbublacky)) ### ✨ Improvements - ⚙️ Added project-level Claude Code settings to remove attribution footers and session links from pull request descriptions. [#1769](https://github.com/commutatus/cm-admin/pull/1769) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Added the v8.3.11 release notes to the October changelog and updated the release index. [34b2661e4](https://github.com/commutatus/cm-admin/commit/34b2661e4) ### v8.3.11 (2026-10-28) ## Catalyst v8.3.11 — 2026-10-28 ### 🚀 Features _No new features in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements _No improvements in this release._ ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Added the v8.3.10 release notes to the October changelog, documenting the initializer configuration change and related documentation updates. [#1739](https://github.com/commutatus/cm-admin/pull/1739) ([#mikevic](https://github.com/mikevic)) - 📚 Updated the release index to include v8.3.10. [337421688](https://github.com/commutatus/cm-admin/commit/337421688) ### v8.3.10 (2026-10-27) ## Catalyst v8.3.10 — 2026-10-27 ### 🚀 Features _No new features in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - ✨ Removed the requirement to name the Catalyst initializer `zcm_admin.rb`. Configuration now runs after all app initializers have loaded, so it can reference models and other initializer-defined constants regardless of filename or load order. Configuration is reapplied on code reloads in development. [#1739](https://github.com/commutatus/cm-admin/pull/1739) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Updated the generator output and documentation to use `config/initializers/cm_admin.rb` as the default filename, and removed the old initializer naming guideline. [#1739](https://github.com/commutatus/cm-admin/pull/1739) ([#mikevic](https://github.com/mikevic)) ### v8.3.9 (2026-10-27) ## Catalyst v8.3.9 — 2026-10-27 ⚠️ **WARNING: Migration Required** — The install generator now includes the `CtFileImport` improvement migration. Existing apps missing the import columns must generate and run it. The migration checks for existing columns before adding them. ```bash rails g cm_admin:file_import_improvement && rails db:migrate ``` ### 🚀 Features - 🚀 Added PR review apps: eligible pull requests to `main` get a deployed feature-app instance, with its own database and file storage, which is removed when the PR closes. [#1721](https://github.com/commutatus/cm-admin/pull/1721) ([#mikevic](https://github.com/mikevic)) - 🌐 Review apps now have Commutatus-branded URLs at `https://pr-.catalyst.platforms.commutatus.com`; the existing `fly.dev` URL remains available. [#1725](https://github.com/commutatus/cm-admin/pull/1725) ([#mikevic](https://github.com/mikevic)) ### 🐛 Bug Fixes - 🐛 Fixed record history pages crashing when loading a record. The history action now fetches the record through the model’s policy scope, matching the show action’s visibility rules. [#1750](https://github.com/commutatus/cm-admin/pull/1750) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) - 🐛 Fixed the install generator omitting the migration that adds required import columns to `ct_file_imports`, which could break file import forms and pages. [#1741](https://github.com/commutatus/cm-admin/pull/1741) ([#mikevic](https://github.com/mikevic)) - 🐛 Fixed migration checking to detect `.tt` migration templates rather than looking for `.rb` files. [#1747](https://github.com/commutatus/cm-admin/pull/1747) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ![Migration template checker](https://github.com/user-attachments/assets/1ceba164-3333-4bfc-9d4b-8bd8474a6dbc) - 🐛 Fixed Postmark and generated policy constant warnings by using the current Postmark error class and replacing generated constants before redefining them. [#1738](https://github.com/commutatus/cm-admin/pull/1738) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) - 🐛 Removed an Rswag UI deprecation warning by switching to `openapi_endpoint`, without changing the API documentation URL or label. [#1730](https://github.com/commutatus/cm-admin/pull/1730) ([#mikevic](https://github.com/mikevic)) ### ✨ Improvements - ✨ Models can now declare enums after including a CmAdmin concern. Enum filters and DSL references are initialized after the model class body has finished loading. [#1740](https://github.com/commutatus/cm-admin/pull/1740) ([#mikevic](https://github.com/mikevic)) - ⚡️ Improved role-permission factory setup to save permissions in bulk, reducing the queries used when creating test admin users. [#1742](https://github.com/commutatus/cm-admin/pull/1742) ([#mikevic](https://github.com/mikevic)) - 🛠️ Asset precompilation and other asset tasks can now boot without a database connection; platform settings sync is skipped for `db:` and `assets:` tasks. [#1716](https://github.com/commutatus/cm-admin/pull/1716) ([#mikevic](https://github.com/mikevic)) - 🔧 Review app deployment now runs after tests pass, and review app workflows skip Dependabot PRs and docs-site-only changes. [#1735](https://github.com/commutatus/cm-admin/pull/1735) ([#mikevic](https://github.com/mikevic)) [#1733](https://github.com/commutatus/cm-admin/pull/1733) ([#mikevic](https://github.com/mikevic)) - 🧰 Tests, linters, and security checks now run on GitHub-hosted runners. CI dependency installation was also streamlined, and the project added StandardRB linting. [#1720](https://github.com/commutatus/cm-admin/pull/1720) ([#mikevic](https://github.com/mikevic)) [#1722](https://github.com/commutatus/cm-admin/pull/1722) ([#mikevic](https://github.com/mikevic)) [#1736](https://github.com/commutatus/cm-admin/pull/1736) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) ### ⚠️ Breaking Changes - ✅ No breaking changes in this release. ### 📚 Documentation - 📚 Added a PR Review Apps guide covering how review apps are deployed and removed, how to access them, their architecture and configuration, and troubleshooting. Added a link to the guide from the Deployment index. [#1737](https://github.com/commutatus/cm-admin/pull/1737) ([#mikevic](https://github.com/mikevic)) - 📚 Removed the enum declaration order rule from the introduction because it is no longer required. [#1740](https://github.com/commutatus/cm-admin/pull/1740) ([#mikevic](https://github.com/mikevic)) ### v8.3.8 (2026-10-23) ## Catalyst v8.3.8 — 2026-10-23 ### 🚀 Features - No new features in this release. ### 🐛 Bug Fixes - 🐛 Fixed export failures for columns where the configured `id` field uses `field_type: :association`. Synthesized export ID columns now use a safe field type and preserve the configured `export_method`, so exports return the associated record’s ID as declared. [#1711](https://github.com/commutatus/cm-admin/pull/1711) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### ✨ Improvements - No additional improvements in this release. ### ⚠️ Breaking Changes - No breaking changes in this release. ### 📚 Documentation - 📚 Updated the October 2026 changelog and release listing with the v8.3.7 release notes. [b160e7172](https://github.com/commutatus/cm-admin/commit/b160e7172) ### v8.3.7 (2026-10-23) ## Catalyst v8.3.7 — 2026-10-23 ### 🚀 Features - 🚀 Added lazy loading for row action dropdowns in index, kanban, and associated table views. Actions are fetched when a dropdown is first opened and cached for subsequent opens, reducing the work performed during initial page loads. [#1578](https://github.com/commutatus/cm-admin/pull/1578) ([#austinjb32](https://github.com/austinjb32)) ![Lazy-loaded row action dropdown](https://github.com/user-attachments/assets/391aa66e-777c-488c-af6a-4a806f5f8bb9) ### 🐛 Bug Fixes - No bug fixes in this release. ### ✨ Improvements - No additional improvements in this release. ### ⚠️ Breaking Changes - No breaking changes in this release. ### 📚 Documentation - 📚 Updated the October 2026 changelog and release listing with v8.3.6 release notes. [6d912bfff](https://github.com/commutatus/cm-admin/commit/6d912bfff) ### v8.3.6 (2026-10-23) ## Catalyst v8.3.6 — 2026-10-23 ### 🚀 Features - No new features in this release. ### 🐛 Bug Fixes - 🐛 Fixed OTP resend integration, expired OTP scope resolution, and password-reset token expiry handling. Added regression coverage for OTP generation, verification, expiry, throttling, sign-in, and password reset. Feature-app OTP delivery remains email-only. [#1713](https://github.com/commutatus/cm-admin/pull/1713) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) ### ✨ Improvements - ✨ Routed MCP requests through a controller so Scout APM can record them, and gave transactions distinct names based on the MCP method and tool. Apps without Scout are unaffected. [#1703](https://github.com/commutatus/cm-admin/pull/1703) ([#mikevic](https://github.com/mikevic)) - ✨ Cached Ruby test dependencies in CI to speed up test runs. [680b968a2](https://github.com/commutatus/cm-admin/commit/680b968a2) ### ⚠️ Breaking Changes - No breaking changes in this release. ### 📚 Documentation - 📚 Updated the MCP server guide with the controller architecture and Scout APM transaction naming details. [#1703](https://github.com/commutatus/cm-admin/pull/1703) ([#mikevic](https://github.com/mikevic)) - 📚 Updated the October 2026 changelog and release listing. [5d2e906d3](https://github.com/commutatus/cm-admin/commit/5d2e906d3) ### v8.3.5 (2026-10-23) ## Catalyst v8.3.5 — 2026-10-23 ### 🚀 Features - No new features in this release. ### 🐛 Bug Fixes - 🐛 Webhook events now display the recipient email from the payload when the associated recipient record is missing, instead of showing “—”. [#1707](https://github.com/commutatus/cm-admin/pull/1707) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 🐛 Fixed exports that failed when an ID column was typed as an association. [9a22abc61](https://github.com/commutatus/cm-admin/commit/9a22abc61) ### ✨ Improvements - ✨ Extracted webhook recipient resolution into the `CmWebhookEvent` model. [e1cec013b](https://github.com/commutatus/cm-admin/commit/e1cec013b) ### ⚠️ Breaking Changes - No breaking changes in this release. ### 📚 Documentation - 📚 Updated the October 2026 changelog with the v8.3.4 release notes and updated the docs-site release listing. [a86031ad8](https://github.com/commutatus/cm-admin/commit/a86031ad8) ### v8.3.4 (2026-10-23) ## Catalyst v8.3.4 — 2026-10-23 ### 🚀 Features - No new features in this release. ### 🐛 Bug Fixes - 🐛 Form validation now checks date fields, handles required selects with no selected value safely, and keeps nested-field validation scoped to the submitted form. [#1675](https://github.com/commutatus/cm-admin/pull/1675) ([#anbublacky](https://github.com/anbublacky)) - 🧭 When validation fails, the page now scrolls to the first invalid field instead of jumping back to the top. [#1675](https://github.com/commutatus/cm-admin/pull/1675) ([#anbublacky](https://github.com/anbublacky)) - 🔁 Fixed the `eventually` test helper so it retries expectations until they pass or the wait time expires. [#1693](https://github.com/commutatus/cm-admin/pull/1693) ([#mikevic](https://github.com/mikevic)) ### ✨ Improvements - ⚡ Parallelized feature-app specs, replaced fixed sleeps with condition checks, disabled animations in system specs, and limited screenshots to failures by default to speed up test runs. [#1693](https://github.com/commutatus/cm-admin/pull/1693) ([#mikevic](https://github.com/mikevic)) - 🧪 Updated the Tests workflow to install JavaScript dependencies with `npm ci` and use the existing cache for faster, lockfile-based installs. [#1705](https://github.com/commutatus/cm-admin/pull/1705) ([#mikevic](https://github.com/mikevic)) - 📦 Updated the docs-site dependencies `follow-redirects` to 1.16.1 and `launch-editor` to 2.14.2. [#1694](https://github.com/commutatus/cm-admin/pull/1694) ([#app/dependabot](https://github.com/app/dependabot)) [#1696](https://github.com/commutatus/cm-admin/pull/1696) ([#app/dependabot](https://github.com/app/dependabot)) ### ⚠️ Breaking Changes - No breaking changes in this release. ### 📚 Documentation - 📚 Updated the October 2026 changelog with the v8.3.3 release notes and updated the docs-site release listing. [fbd4391d4](https://github.com/commutatus/cm-admin/commit/fbd4391d4) ### v8.3.3 (2026-10-23) ## Catalyst v8.3.3 — 2026-10-23 > ⚠️ **WARNING: Breaking Changes** — `status_helper_icon` was renamed to `cm_info_tooltip`. Applications that call `status_helper_icon` directly must update those calls. > > 🔄 **WARNING: Migration Required** — Existing installs need new `cm_webhook_events` recipient and clicked-link columns for per-recipient tracking. Fresh installs do not need this extra generator. > > Run on existing installs: > ```bash > rails generate cm_admin:add_webhook_event_recipient > rails db:migrate > ``` ### 🚀 Features - 📬 Added per-recipient email open and click tracking to the Postmark webhook pipeline. Events are linked to matching recipients, and clicked URLs are stored for reporting. CC/BCC events remain unattributed to the To recipient. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 📊 Expanded the Email Log page with recipient open and click counts, first-open timestamps, click details with click rates, and an Events tab. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 🟣 Added a `clicked` recipient status after `opened`, including status-transition rules and badge styling. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### 🐛 Bug Fixes - No bug fixes in this release. ### ✨ Improvements - 🔁 Identical Postmark webhook redeliveries now resolve to the original event, preventing duplicate open and click counts. The full supported webhook payload is also stored. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 💡 Renamed the `status_helper_icon` view helper to `cm_info_tooltip` and set its Bootstrap tooltip container to `body` so tooltips render correctly inside scrollable containers. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### ⚠️ Breaking Changes - ⚠️ Applications that call `status_helper_icon` directly must change those calls to `cm_info_tooltip`. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### 📚 Documentation - 📚 Added the v8.3.3 upgrade guide and documented webhook recipient attribution, clicked-link storage, and the new generator. The upgrade guide explains migration steps, fresh-install behavior, and that historical events are not backfilled. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 📝 Updated the October 2026 changelog with v8.3.2 release notes. [0b8fc45a1](https://github.com/commutatus/cm-admin/commit/0b8fc45a1) ### v8.3.2 (2026-10-22) ## Catalyst v8.3.2 — 2026-10-22 ### 🚀 Features - No new features in this release. ### 🐛 Bug Fixes - 🛡️ Fixed authorization for member custom actions under the index page. These actions now load the target record through the policy scope and authorize it before running, preventing users from invoking an action on records they cannot access. [#1704](https://github.com/commutatus/cm-admin/pull/1704) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### ✨ Improvements - No other improvements in this release. ### ⚠️ Breaking Changes - No breaking changes. ### 📚 Documentation - 📚 Updated the October 2026 changelog and release index with the v8.3.1 release notes. [1a2ba4104](https://github.com/commutatus/cm-admin/commit/1a2ba4104) ### v8.3.1 (2026-10-22) ## Catalyst v8.3.1 — 2026-10-22 ### 🚀 Features - ✨ Added an optional header icon button next to the page title, configurable with an icon, URL, tooltip, and visibility condition. ([#1659](https://github.com/commutatus/cm-admin/pull/1659)) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - ✨ Upgraded page descriptions to sanitized rich text, shown on pages with the top navbar and truncated with a View more / View less toggle. ([#1659](https://github.com/commutatus/cm-admin/pull/1659)) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### 🐛 Bug Fixes - 🐛 Improved page-description toggle positioning and tooltip offset, with better ellipsis alignment. ([5cc84656e](https://github.com/commutatus/cm-admin/commit/5cc84656e)) - 🐛 Adjusted page-description toggle positioning and ellipsis alignment for a cleaner truncated layout. ([5b620bb72](https://github.com/commutatus/cm-admin/commit/5b620bb72)) ### ✨ Improvements - 📦 Changed gem packaging to an allow list so releases include runtime files, deployment configuration, and Markdown documentation without unnecessary development files. ([#1689](https://github.com/commutatus/cm-admin/pull/1689)) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes - No breaking changes. ### 📚 Documentation - 📚 Expanded the DSL reference with page-description rich-text behavior and examples, plus the title-icon options and usage example. ([#1659](https://github.com/commutatus/cm-admin/pull/1659)) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### v8.3.0 (2026-10-22) ## Catalyst v8.3.0 — 2026-10-22 ### 🚀 Features - No new user-facing features in this release. ### 🐛 Bug Fixes - 🛡️ Hardened custom action modal handling with constantization safety, a nil guard, and action-name validation to address a Brakeman-reported remote code execution risk. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32)) - 🐛 Fixed filter display text for collections with a single option. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - ✨ Refactored resource actions into dedicated services and moved exports and page-view helpers to the service layer, leaving controllers thinner without changing behavior. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32)) - ✨ Converted form validation to a Stimulus controller while preserving validation, discard-button, unsaved-change tracking, and leave-page guard behavior. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32)) - ✨ Cleaned up JavaScript controllers and vendored styling, and aligned feature-app schemas and specs with existing behavior. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32)) ### ⚠️ Breaking Changes - No breaking changes. ### 📚 Documentation - No documentation changes for this release. ### v8.2.18 (2026-10-22) ## Catalyst v8.2.18 — 2026-10-22 > ⚠️ **WARNING: Breaking Changes** — API token `description` is replaced by `name`. The legacy description generator is removed, and existing description values are not copied to the new field. > > 🔄 **WARNING: Migration Required** — Generate and run the API token name migration. It adds and backfills `name`, then drops `description` if present: > ```bash > rails generate cm_admin:add_name_to_api_token > rails db:migrate > ``` ### 🚀 Features - 🚀 API tokens now have a human-readable `name`, shown in the index and token details. The index supports searching by name, and tokens can be renamed from a new edit page. ([#1697](https://github.com/commutatus/cm-admin/pull/1697)) ([#anbublacky](https://github.com/anbublacky)) - 🚀 Markdown documentation is bundled with the gem so coding agents can access documentation that matches the installed version. ([#1695](https://github.com/commutatus/cm-admin/pull/1695)) ([#mikevic](https://github.com/mikevic)) ### 🐛 Bug Fixes - No bug fixes in this release. ### ✨ Improvements - ✨ The documentation site now publishes `/llms.txt`, `/llms-full.txt`, and Markdown versions of its pages for coding agents. ([#1695](https://github.com/commutatus/cm-admin/pull/1695)) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes - ⚠️ API tokens replace the v8.2.14 `description` field with `name`. The name is limited to 100 characters and is required for personal access tokens. The old `cm_admin:add_description_to_api_token` generator has been removed; the new migration backfills existing tokens with `Unnamed Token` and drops `description` without carrying its values over. Add a name wherever your application creates tokens directly. ([#1697](https://github.com/commutatus/cm-admin/pull/1697)) ([#anbublacky](https://github.com/anbublacky)) ### 📚 Documentation - 📚 Added a v8.2.18 upgrade guide with migration steps and compatibility notes; updated the generator reference and MCP token example for the new `name` field. ([#1697](https://github.com/commutatus/cm-admin/pull/1697)) ([#anbublacky](https://github.com/anbublacky)) - 📚 Updated the documentation introduction with guidance for AI coding agents, including how to find the gem-bundled Markdown docs and the website’s LLM-friendly files. ([#1695](https://github.com/commutatus/cm-admin/pull/1695)) ([#mikevic](https://github.com/mikevic)) ### v8.2.17 (2026-10-20) ## Catalyst v8.2.17 — 2026-10-20 ### 🚀 Features - No new features in this release. ### 🐛 Bug Fixes - No bug fixes in this release. ### ✨ Improvements - ✨ Updated the gemspec to accurately require Rails 8.0.2 or later, clarifying the minimum version supported by Catalyst’s dependencies. ([#1688](https://github.com/commutatus/cm-admin/pull/1688)) ([#mikevic](https://github.com/mikevic)) - ✨ Streamlined release automation: version and lockfile updates are committed together, and tests are skipped for the docs-only changelog commit. ([#1690](https://github.com/commutatus/cm-admin/pull/1690)) ([#mikevic](https://github.com/mikevic)) - ✨ Updated the gemspec’s author list and email addresses. ([#1687](https://github.com/commutatus/cm-admin/pull/1687)) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes - No breaking changes. ### 📚 Documentation - 📚 Updated the DSL reference to list valid field types and use supported `cm-badge-*` classes in examples. The status badge guide now documents the `:badge` and `badge_class` names, includes a usage example, and clarifies that the former `:tag` name and legacy badge classes are no longer supported. ([#1691](https://github.com/commutatus/cm-admin/pull/1691)) ([#mikevic](https://github.com/mikevic)) - 📚 Corrected the `column` method’s YARD comment to match the field types accepted by the code. ([#1692](https://github.com/commutatus/cm-admin/pull/1692)) ([#mikevic](https://github.com/mikevic)) ### v8.2.16 (2026-10-19) ## Catalyst v8.2.16 — 2026-10-19 ### 🚀 Features - No new features in this release. ### 🐛 Bug Fixes - 🐛 Fixed the default datetime display format to show a consistent 12-hour time with AM/PM, matching the flatpickr time picker. ([#1686](https://github.com/commutatus/cm-admin/pull/1686)) ([#austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - No other improvements in this release. ### ⚠️ Breaking Changes - No breaking changes. ### 📚 Documentation - 📚 Updated the October 2026 changelog with the v8.2.15 release notes and refreshed the releases index. ([604b1306b](https://github.com/commutatus/cm-admin/commit/604b1306b)) ### v8.2.15 (2026-10-19) ## Catalyst v8.2.15 — 2026-10-19 ### 🚀 Features - 🚀 Added the `card_title: true` column option to choose the title displayed in card and kanban views. If none is configured, the first displayable column is used; remaining columns appear as subtitles. ([#1685](https://github.com/commutatus/cm-admin/pull/1685)) ([#austinjb32](https://github.com/austinjb32)) ### 🐛 Bug Fixes - 🐛 Fixed the index toolbar so `view_type: :kanban` correctly enables the kanban view toggle. ([#1685](https://github.com/commutatus/cm-admin/pull/1685)) ([#austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - No other improvements in this release. ### ⚠️ Breaking Changes - No breaking changes. ### 📚 Documentation - 📚 Documented the `card_title` option in the column DSL reference, including its default behavior when no title column is configured. ([#1685](https://github.com/commutatus/cm-admin/pull/1685)) ([#austinjb32](https://github.com/austinjb32)) ### v8.2.14 (2026-10-19) ## Catalyst v8.2.14 — 2026-10-19 🔄 **WARNING: Migration Required** — This release adds a `description` column to API tokens. Generate and run the migration before using the new field: ```bash rails generate cm_admin:add_description_to_api_token rails db:migrate ``` > ⚠️ **Superseded in a later release**: the `description` column was replaced by a mandatory `name` column, and the `cm_admin:add_description_to_api_token` generator was removed. If you are upgrading past v8.2.14, run `rails generate cm_admin:add_name_to_api_token` instead — its migration adds `name` and drops `description`. ### 🚀 Features - 🚀 API tokens can now have a human-readable description, displayed on the token show page and available on the new-token form. ([#1684](https://github.com/commutatus/cm-admin/pull/1684)) ([#anbublacky](https://github.com/anbublacky)) ### 🐛 Bug Fixes - No user-facing bug fixes in this release. ### ✨ Improvements - ✨ Restored the automated test suite and improved CI reliability, including independent Rails-version jobs and a longer test timeout. ([#1677](https://github.com/commutatus/cm-admin/pull/1677)) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes - No breaking changes. ### 📚 Documentation - 📚 Added the API-token description generator to the generator reference, including its command, purpose, and migration step. ([#1684](https://github.com/commutatus/cm-admin/pull/1684)) ([#anbublacky](https://github.com/anbublacky)) - 📚 Updated the October 2026 changelog and release registry with v8.2.13 release information. ([4fd0459fd](https://github.com/commutatus/cm-admin/commit/4fd0459fd)) ### v8.2.13 (2026-10-19) ## Catalyst v8.2.13 — 2026-10-19 ### 🚀 Features - 🚀 Advanced filters now apply when the dropdown closes—whether by clicking outside, pressing Esc, or selecting Apply—so configured filters take effect without requiring a separate Apply click. ([#1681](https://github.com/commutatus/cm-admin/pull/1681)) [@Mahaveer1141](https://github.com/Mahaveer1141) ### ✨ Improvements - ✨ Improved saved-view buttons on smaller screens: below the 991px breakpoint, the button shows a view-type icon instead of the name and label. View-name width now scales responsively, and buttons have a consistent minimum height. ([#1681](https://github.com/commutatus/cm-admin/pull/1681)) [@Mahaveer1141](https://github.com/Mahaveer1141) ### 📚 Documentation - 📚 Updated the October 2026 changelog with the v8.2.12 release notes and added that release to the releases registry. ([4fd0459fd](https://github.com/commutatus/cm-admin/commit/4fd0459fd)) ### v8.2.12 (2026-10-19) ## Catalyst v8.2.12 — 2026-10-19 ### 🐛 Bug Fixes - 🐛 Fixed built-in `filter_with` scopes that raised an `ArgumentError` because they received an unexpected second argument. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic) - 🐛 Fixed partial range filters so a range such as `30 to ` matches values at or above 30, rather than only values equal to 30. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic) - 🐛 Fixed single-option select filter chips to show the option label instead of its raw ID. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic) - 🐛 Fixed sorting with an unknown column to fall back to the default sort, and made malformed sort parameters safely ignored. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic) - 🐛 Fixed multi-word search so every search term must match, rather than allowing results that match only one term. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic) ### 📚 Documentation - 📚 Updated the October 2026 changelog with the v8.2.11 release entry and added that release to the releases registry. ([629689839](https://github.com/commutatus/cm-admin/commit/629689839)) ### v8.2.11 (2026-10-19) ## Catalyst v8.2.11 — 2026-10-19 > ⚠️ **WARNING: Breaking Changes** — Deployment notifications no longer use the webhook URL embedded in the Hatchbox config. Set `SLACK_WEBHOOK_URL` in your Hatchbox environment to continue receiving notifications. No database migration or rake task is required. ### 🚀 Features - ✅ Added a `(Select All)` option to standard and advanced `multi_select` filters. It toggles all options, including `(Empty)`, and can be disabled with `allow_select_all_option: false`. The control is UI-only; only selected filter values are submitted. ([#1663](https://github.com/commutatus/cm-admin/pull/1663)) [@Mahaveer1141](https://github.com/Mahaveer1141) ### 🐛 Bug Fixes - 🐛 Fixed handling of an unset Hatchbox Slack webhook: the scripts now warn that `SLACK_WEBHOOK_URL` is missing and safely skip sending a notification. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32) ### ✨ Improvements - 🛡️ Removed the hardcoded Slack webhook secret and the config-unavailable alert and related code. Deployment notifications now use only the `SLACK_WEBHOOK_URL` environment variable. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32) ### ⚠️ Breaking Changes - ⚠️ Hatchbox deployments that relied on the embedded webhook URL must now set `SLACK_WEBHOOK_URL`; without it, notifications are skipped. No database migration or rake task is required. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32) ### 📚 Documentation - 📚 Updated the filter DSL reference with the `allow_select_all_option` parameter and an example. ([#1663](https://github.com/commutatus/cm-admin/pull/1663)) [@Mahaveer1141](https://github.com/Mahaveer1141) - 📚 Updated Hatchbox deployment documentation to describe environment-only webhook configuration and remove obsolete config-alert and environment-variable details. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32) - 📚 Updated the October 2026 changelog with the v8.2.10 release entry. ([9f12e37ef](https://github.com/commutatus/cm-admin/commit/9f12e37ef)) ### v8.2.10 (2026-10-16) - Merge pull request #1671 from commutatus/fix/CT-324-papertrail-argument-error (acfe25f2d) - docs: update changelog for v8.2.9 (07bcc2cc6) - fix(papertrail): support positional options in has_paper_trail wrapper (f8344d812) ### v8.2.9 (2026-10-16) - Merge pull request #1669 from commutatus/fix/release-notes-context (a071e5794) - feat(release): render Slack highlights as bullet points (216aa59c2) - feat(release): credit PR contributors in generated release notes (45f250d06) - fix(release): anchor docs insertion to page title and exclude prerelease tags (8ac94ebf7) - docs(changelog): correct October 2026 release entries (e804aa376) - fix(release): scope PR context to tag range and pass release date to notes prompt (2ed87033d) - docs: update changelog for v8.2.8 (9151b4959) ### v8.2.8 (2026-10-15) ## Catalyst v8.2.8 — October 15, 2026 ### 🐛 Bug Fixes - 📤 Export modals are now limited to the appropriate relationship pages. On `has_one` tabs, neither export modal is rendered; index and associated export modals are mutually exclusive. [#1668](https://github.com/commutatus/cm-admin/pull/1668) ([@anbublacky](https://github.com/anbublacky)) ### ✨ Improvements _No improvements in this release._ ### 📚 Documentation _No documentation changes in this release._ ### v8.2.7 (2026-10-15) ## Catalyst v8.2.7 — October 15, 2026 ### 🐛 Bug Fixes - 🐛 Skip rendering the associated export modal on `has_one` tab pages, where there is no export trigger and the modal could show an invalid column list. The index and associated export modals are now mutually exclusive. [#1667](https://github.com/commutatus/cm-admin/pull/1667) ([@anbublacky](https://github.com/anbublacky)) ### 📚 Documentation - 📝 Updated the changelog and release listing with the v8.2.6 release notes. [5922e34f3](https://github.com/commutatus/cm-admin/commit/5922e34f3) ### v8.2.6 (2026-10-15) ## Catalyst v8.2.6 — October 15, 2026 ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🧹 Update the filters partial path used by the dashboard view. [#1665](https://github.com/commutatus/cm-admin/pull/1665) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### 📚 Documentation - 📝 Updated the changelog and release listing. [4139554ec](https://github.com/commutatus/cm-admin/commit/4139554ec) ### v8.2.5 (2026-10-15) ## Catalyst v8.2.5 — October 15, 2026 ### 🚀 Features - 📜 **Associated record history** — Record history pages can now show the version history of nested and associated records together with the parent record's changes, so all related activity appears in one place. To enable it, include `CmAdmin::PaperTrailAssociations` in `ApplicationRecord` and declare the associations to track on each model: ```ruby has_paper_trail paper_trail_associations :po_items, :po_parts, :attachments ``` History entries render each record's display name, and unattributed changes are credited to "System user" instead of "Anonymous". Works with `has_many` and `has_many :through` associations, and falls back to the previous behaviour when `all_versions_including_associations` is unavailable. [#1087](https://github.com/commutatus/cm-admin/pull/1087) ([@anbublacky](https://github.com/anbublacky)) [Watch the walkthrough](https://videos.commutatus.com/videos/cmadmin-nested-table-history) ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 📦 **Dependency updates** — Bumped `activestorage` to 8.1.3.1 in the feature app, `colord` to 2.10.0, and `@ai-sdk/provider-utils`, `@ai-sdk/react`, and `ai` in the docs site. [#1585](https://github.com/commutatus/cm-admin/pull/1585) ([@dependabot[bot]](https://github.com/apps/dependabot)) [#1589](https://github.com/commutatus/cm-admin/pull/1589) ([@dependabot[bot]](https://github.com/apps/dependabot)) [#1590](https://github.com/commutatus/cm-admin/pull/1590) ([@dependabot[bot]](https://github.com/apps/dependabot)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📖 **Associated record history guide** — Added `AssociatedRecordHistory.md` covering `CmAdmin::PaperTrailAssociations` and `paper_trail_associations` setup. [#1087](https://github.com/commutatus/cm-admin/pull/1087) ([@anbublacky](https://github.com/anbublacky)) - 📝 Updated the changelog and release listing with the v8.2.4 release notes. [dd6769432](https://github.com/commutatus/cm-admin/commit/dd6769432) ### v8.2.4 (2026-10-15) ## Catalyst v8.2.4 — October 15, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🔐 **Respect record access when rendering links:** Association fields and exported “Record ID” values now render as plain text when the user cannot access the associated record. The access check also accounts for record-level policy scopes and fails closed if policy resolution encounters an error. [#1646](https://github.com/commutatus/cm-admin/pull/1646) ([@anbublacky](https://github.com/anbublacky)) ### ✨ Improvements _No improvements in this release._ ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📝 Updated the v8.2.3 changelog. [9fc6c7064](https://github.com/commutatus/cm-admin/commit/9fc6c7064) ### v8.2.3 (2026-10-14) ## Catalyst v8.2.3 — October 14, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 💬 **Hatchbox deployment notifications:** Restored a compact Slack layout with a status title, one-line message, and action buttons. Added fallback notification text for mobile previews; failure notifications retain their visual emphasis. [#1658](https://github.com/commutatus/cm-admin/pull/1658) ([@austinjb32](https://github.com/austinjb32)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📖 Updated the HatchboxScripts guide to describe the compact Slack notification format and renamed “View on Hatchbox” button. [#1658](https://github.com/commutatus/cm-admin/pull/1658) ([@austinjb32](https://github.com/austinjb32)) ### v8.2.2 (2026-10-13) ## Catalyst v8.2.2 **Date:** October 13, 2026 ### 🚀 Features - 🔐 Redesigned OTP verification with six individual digit boxes, paste and autofill support, keyboard navigation, and a resend countdown for sign-in and password-reset flows. [#1656](https://github.com/commutatus/cm-admin/pull/1656) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - ⌨️ OTP entry supports SMS one-time-code autofill, paste across digit boxes, and keyboard navigation. [#1656](https://github.com/commutatus/cm-admin/pull/1656) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Updated the changelog with the v8.2.1 release notes. [f33ebeb82](https://github.com/commutatus/cm-admin/commit/f33ebeb82) ### v8.2.1 (2026-10-13) ## Catalyst v8.2.1 **Date:** October 13, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🧱 Fixed show-page `card-info` grids overflowing when descriptions contain long, unbreakable content such as URLs or long IDs. Descriptions now wrap, and embedded images scale to fit their container. [#1657](https://github.com/commutatus/cm-admin/pull/1657) ([@BrijeshSajeev](https://github.com/BrijeshSajeev)) ### ✨ Improvements _No improvements in this release._ ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Updated the changelog with the v8.2.0 release notes. [98c9c7937](https://github.com/commutatus/cm-admin/commit/98c9c7937) ### v8.2.0 (2026-10-13) ## Catalyst v8.2.0 **Date:** October 13, 2026 ⚠️ **WARNING: Breaking Changes** — `filter_with` scopes for `:single_select`, `:multi_select`, `:date`, and `:range` filters now receive `(value, condition)`. Update these scopes to accept the new arguments; date and range scopes now receive the raw value string instead of separate `from` and `to` values. [Upgrade guide](../5.%20Upgrade%20Guide/v8.2.0.md) 🔄 **WARNING: Migration Required** — Saved Views replace Index Preferences. Generate and run the migration to create `ct_page_views`; it drops `cm_index_preferences` without migrating its data. Back up that table first if you need to retain its data. ```bash rails generate cm_admin:ct_page_view rails db:migrate ``` ### 🚀 Features - 🔎 **Advanced Filters:** Build nested condition groups using AND/OR logic and field-specific operators such as `is_not`, `greater_than`, and `is_empty`. Advanced conditions work with supported select, date, and range filters. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - 💾 **Saved Views:** Save and reuse per-user index configurations, including filters, sort order, visible columns, page size, and view type. Views can be duplicated, set as defaults, and shared through links. This replaces Index Preferences. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🔗 Filter parameters are now compressed in URLs using zlib and base64url-encoded JSON. Existing nested filter hashes and plain JSON remain supported; use `CmAdmin::Utils.decode_filter_params` when reading filter parameters in application code. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - 🎛️ Advanced filters support serialized and restorable filter state, dynamic operator options, and preservation of URL parameters when saved views are applied or cleared. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes - 🛠️ **`filter_with` scope signatures changed:** Select, date, and range filter scopes now receive `(value, condition = nil)`. Date and range scopes receive the raw value string (for example, `"18 to 30"`) instead of separate `from` and `to` arguments. Update lambda scopes to accept the new signature; strict-arity lambdas can raise `ArgumentError` otherwise. Search scopes are unchanged. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - 🗂️ **Index Preferences were removed:** `CmIndexPreference` and `cm_index_preferences` are replaced by Saved Views and `CtPageView`. The generated migration drops the old table, and its data is not migrated. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### 📚 Documentation - 📚 Added guides for Advanced Filters and Saved Views, expanded custom filter scope documentation, documented the `ct_page_view` generator, and added a v8.2.0 upgrade guide covering breaking changes, migration steps, and filter URL handling. [c58bc0fcd](https://github.com/commutatus/cm-admin/commit/c58bc0fcd) ### v8.1.15 (2026-10-13) ## Catalyst v8.1.15 **Date:** October 13, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🔎 Single- and multi-select filters now respect `allow_empty_option: false`. Single-option filters also display the selected option’s label in the chip instead of its raw value. [#1650](https://github.com/commutatus/cm-admin/pull/1650) ([@austinjb32](https://github.com/austinjb32)) ### ✨ Improvements _No improvements in this release._ ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Updated the monthly changelog and release metadata with the v8.1.14 release notes. [e301a8196](https://github.com/commutatus/cm-admin/commit/e301a8196) ### v8.1.14 (2026-10-13) ## Catalyst v8.1.14 **Date:** October 13, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🥧 Pie charts now show labels by default, with a formatter displaying each slice’s name, value, and percentage. [#1655](https://github.com/commutatus/cm-admin/pull/1655) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ![Pie chart with labels](https://github.com/user-attachments/assets/465442c0-f5c3-4970-9d40-05846f02e646) ![Pie chart label details](https://github.com/user-attachments/assets/9824141d-1e7f-42bb-ad18-f912eaecad81) ### ✨ Improvements _No improvements in this release._ ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Updated the monthly changelog and release metadata with v8.1.13 release notes. [`9929c567a`](https://github.com/commutatus/cm-admin/commit/9929c567a) ### v8.1.13 (2026-10-13) ## Catalyst v8.1.13 **Date:** October 13, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🧭 Custom button and link actions in dropdown and popup options now navigate outside Turbo Frames, ensuring navigation loads the full page. [#1654](https://github.com/commutatus/cm-admin/pull/1654) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### ✨ Improvements _No improvements in this release._ ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation _No documentation changes in this release._ ### v8.1.12 (2026-10-09) ## Catalyst v8.1.12 — October 9, 2026 ### 🚀 Features - 🔎 Global search now includes configured sidebar links, including nested links. Sidebar labels and model names remain searchable, and matching suggestions use configured labels and icons without duplicate URLs. [#1643](https://github.com/commutatus/cm-admin/pull/1643) ([@anbublacky](https://github.com/anbublacky)) [Watch the video explanation](https://www.loom.com/share/555c5a336bec41b8931d376028f18491) ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 📦 Consolidated 16 dependency updates across the gem, feature app, docs site, and root npm workspace. This includes Devise 4.9.4 → 5.0.4, a major-version upgrade, along with Ruby and JavaScript dependency updates. [#1651](https://github.com/commutatus/cm-admin/pull/1651) ([@anbublacky](https://github.com/anbublacky)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📚 Clarified when `@policy_model` is reset during sidebar link search. [Commit `49cb47bb6`](https://github.com/commutatus/cm-admin/commit/49cb47bb6) - 📚 Updated the docs-site changelog for v8.1.11. [Commit `a19d122ba`](https://github.com/commutatus/cm-admin/commit/a19d122ba) ### v8.1.11 (2026-10-09) ## Catalyst v8.1.11 — October 9, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🐛 Updated the dashboard table widget layout so descriptions wrap beneath the heading at full width, while the sort and export buttons remain aligned beside the title. This prevents long descriptions from being clipped or crowding the action buttons. [#1647](https://github.com/commutatus/cm-admin/pull/1647) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) **Before** ![Dashboard table header before the layout change](https://github.com/user-attachments/assets/1b9b58da-8ead-49ec-a241-b405535d5eb2) **After** ![Dashboard table header after the layout change](https://github.com/user-attachments/assets/9e81317c-e192-4a60-80c8-e202a95b19b3) ### ✨ Improvements - ✨ Updated the Lexxy rich text editor dependency to stable version 1.0.0, moving Catalyst from the 0.x beta series. Existing Action Text integration remains compatible. [#1649](https://github.com/commutatus/cm-admin/pull/1649) ([@barath46](https://github.com/barath46)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation _No documentation changes in this release._ ### v8.1.10 (2026-10-09) ## Catalyst v8.1.10 **Date:** October 9, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🎨 **Navigation icons** — Updated the `CmRole` and `CmCronJob` icons so they are defined consistently in the models’ admin configuration. [#1645](https://github.com/commutatus/cm-admin/pull/1645) ([@venky-21509](https://github.com/venky-21509)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📖 **Changelog** — Updated the documentation changelog with the v8.1.9 release notes, covering that release’s improvements and documentation updates. [05f3e912b](https://github.com/commutatus/cm-admin/commit/05f3e912b) ### v8.1.9 (2026-10-08) ## Catalyst v8.1.9 **Date:** October 8, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🧾 **MCP field schemas and serialization** — Improve enum handling, include `nil` for nullable enum values, and add explicit string casting and type conversion for field values. [#1644](https://github.com/commutatus/cm-admin/pull/1644) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📖 **Changelog** — Added the v8.1.8 release notes to the documentation changelog, summarizing that release’s features, fixes, improvements, and documentation updates. [af79b70ee](https://github.com/commutatus/cm-admin/commit/af79b70ee) ### v8.1.8 (2026-10-08) ## Catalyst v8.1.8 **Date:** October 8, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🧮 **MCP decimal serialization** — Normalize `BigDecimal` field values to floats for JSON compatibility, including values returned through the `serializable_hash` fallback. [#1642](https://github.com/commutatus/cm-admin/pull/1642) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation _No documentation changes in this release._ ### v8.1.7 (2026-10-08) ## Catalyst v8.1.7 **Date:** October 8, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🧩 **MCP field serialization** — Normalized `BigDecimal` field values to floats for JSON compatibility, including values returned through the `serializable_hash` fallback. [#1641](https://github.com/commutatus/cm-admin/pull/1641) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📖 **Changelog** — Added the v8.1.6 release notes to the documentation changelog, including summaries of that release’s features, fixes, improvements, and documentation updates. [1317b17d3](https://github.com/commutatus/cm-admin/commit/1317b17d3) ### v8.1.6 (2026-10-08) ## Catalyst v8.1.6 **Date:** October 8, 2026 ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🧩 **MCP field schemas** — Improved enum handling by mapping explicit enum fields to strings and using enum values in the schema. Field schemas now consistently support nullable types. [#1640](https://github.com/commutatus/cm-admin/pull/1640) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📖 **Changelog** — Added v8.1.5 release notes to the documentation changelog, covering its features, bug fixes, improvements, and webhook setup details. [be3427d6d](https://github.com/commutatus/cm-admin/commit/be3427d6d) ### v8.1.5 (2026-10-08) ## Catalyst v8.1.5 **Date:** October 8, 2026 ### 🐛 Bug Fixes _No bug fixes in this release._ ### ✨ Improvements - 🎫 **Support-ticket validation and migration templates** — Scoped `platform_ticket_id` uniqueness validation to the Catalyst installation, improved type checking and code consistency, and changed foreign key columns in the migration template from integer to bigint. The template change applies to migrations generated in the future; no database migration is required for this release. [#1639](https://github.com/commutatus/cm-admin/pull/1639) ([@Mahaveer1141](https://github.com/Mahaveer1141)) ### 📚 Documentation - 📖 **Changelog** — Added the v8.1.4 release notes to the documentation changelog, including feature, bug-fix, improvement, and webhook setup details. [baad7e483](https://github.com/commutatus/cm-admin/commit/baad7e483) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### v8.1.4 (2026-10-08) ## Catalyst v8.1.4 **Released:** October 8, 2026 ### 🚀 Features - 🪝 **Webhook event logging and Postmark delivery tracking** — Added a webhook logging system with asynchronous processing, an admin UI for inspecting events, and Postmark delivery tracking with recipient-level statuses. Webhook payloads are stored through ActiveStorage, and events can be associated with records for debugging and auditing. [#1507](https://github.com/commutatus/cm-admin/pull/1507) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ![Email log delivery tracking](https://github.com/user-attachments/assets/85866852-ff8a-46c6-a605-95e9958283aa) ![Email log recipient statuses](https://github.com/user-attachments/assets/0989a589-0047-4bf3-952c-977626124de7) ![Social beat email log](https://github.com/user-attachments/assets/12d20ab0-3355-4dfa-abae-0868cd092f65) ![Email log attachment](https://github.com/user-attachments/assets/49f45ffd-d70b-4bcf-bca2-32999b793325) ![Email log attachment](https://github.com/user-attachments/assets/4d1dfa92-d852-409d-ab91-0a9431f0b4dc) - 📝 **Page descriptions in the index navbar** — Index pages can now display a page description in the top navbar, with styling for secondary text and links. [#1632](https://github.com/commutatus/cm-admin/pull/1632) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ![Index page description in the navbar](https://github.com/user-attachments/assets/85b3934f-4578-4eac-bb31-62262c986ce8) ### 🐛 Bug Fixes - 🛠️ **Release workflow SIGPIPE failures** — Fixed release context collection failing when `git log` output was piped through `head`. The workflow now passes context using file paths, avoiding command-line size limits and allowing larger docs diffs. [#1638](https://github.com/commutatus/cm-admin/pull/1638) ([@austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - ⚙️ **Devise OTP initialization** — Register Devise modules through `config.to_prepare`, add an autoload for `CmAdminAuthenticatable`, and avoid OTP checks when a custom authentication method is configured. [#1633](https://github.com/commutatus/cm-admin/pull/1633) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - ✉️ **Email log display titles** — Email log pages now use a display title in the `[id] subject` format. [#1630](https://github.com/commutatus/cm-admin/pull/1630) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 🪝 **Webhook processing and event records** — Webhook handling now uses a generic router and service-based processing, with payload attachments, UUIDs, and event associations. [#1507](https://github.com/commutatus/cm-admin/pull/1507) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### 📚 Documentation - 📖 **Webhook setup and generator guides** — Added a webhook logging guide covering configuration, controller overrides, routing, processing, and the admin UI. Updated the generator reference with `cm_admin:add_webhook_events`, its migration details, and the ActiveStorage setup requirement. [#1636](https://github.com/commutatus/cm-admin/pull/1636) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 🧾 **Release documentation** — Updated the release changelog documentation with the current default notes-generation model. [#1638](https://github.com/commutatus/cm-admin/pull/1638) ([@austinjb32](https://github.com/austinjb32)) ### ⚠️ Breaking Changes _No breaking changes in this release._ > **Optional webhook setup:** To enable webhook event logging, generate the table and run the migration. Webhook payload storage also requires ActiveStorage to be installed and configured. > > ```bash > rails generate cm_admin:add_webhook_events > rails db:migrate > rails active_storage:install > ``` ### v8.0.16 (2026-10-05) ## v8.0.16 **Released:** October 5, 2026 --- ### ✨ Improvements - ♻️ **OTP generation and verification logic consolidated** — The OTP flow has been refactored into a single, self-contained path on `OtpRequest`, removing the previous dependency on a `user` object. This makes OTP handling easier to reason about and reuse across email- and non-email-based delivery. — [#1626](https://github.com/commutatus/cm-admin/pull/1626) ([@Mahaveer1141](https://github.com/Mahaveer1141)) - 🧮 **New `OTP_EXPIRY_IN_MINUTES` constant** — Extracted the expiry window into a single constant, now used by both the `timed_out` and `active` scopes so expiry logic can't drift between them. - 🏗️ **New `OtpRequest.generate!` class method** — Handles OTP creation end-to-end, including automatic cleanup of expired and active requests before generating a new one. - ✅ **New `OtpRequest.verify?` class method** — Validates a submitted OTP and updates the relevant statuses in one call. - 📨 **`deliver_otp` simplified** — Now always calls `send_otp` instead of branching on whether a user or a raw email was supplied. - ✉️ **`deliver_via_email` signature updated** — Accepts the email address directly as a parameter rather than resolving it from a user. > ℹ️ These are internal API changes. If your application overrides or calls `deliver_otp` / `deliver_via_email` directly, update the call sites to pass the email explicitly. ### 📚 Documentation - 📝 **Changelog updated for v8.0.15** — Added the `v8.0.15` entry to `docs-site/docs/Changelog/2026-10.md`, covering the lazy-loaded dashboard widgets, the nested-forms view-toggle fix, and the env-driven Hatchbox deploy scripts (including the `deployment_alerts` breaking change and the `deploy_config:sync` migration steps). — [dc6bad29f](https://github.com/commutatus/cm-admin/commit/dc6bad29f) - 🗂️ **Release metadata refreshed** — `docs-site/static/releases.json` updated so the new changelog entry is picked up by the docs site. — [dc6bad29f](https://github.com/commutatus/cm-admin/commit/dc6bad29f) ### v8.0.15 (2026-10-01) ## v8.0.15 **Released:** October 1, 2026 --- ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🩹 **Nested forms now initialize and toggle the configured view correctly** — The active state is kept in sync between the nested table and grid view controls, and nested forms initialize in grid mode when `default_view_type` is configured as `:grid`. Previously the selected grid icon did not match the rendered table layout, and switching views left the previous control active. Fixed by adding the selector attribute expected by the Stimulus view-toggle handler to both controls and applying the `grid-table` class during server-side rendering when grid is the configured default. — [#1623](https://github.com/commutatus/cm-admin/pull/1623) ([@anbublacky](https://github.com/anbublacky)) ### ✨ Improvements - 🔄 **Project workflow rules and lockfile platform entries synced** — Refreshed the project's AI workflow rules and `yarn.lock` platform-specific optional package entries. — [#1623](https://github.com/commutatus/cm-admin/pull/1623) ([@anbublacky](https://github.com/anbublacky)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📝 **Changelog and release metadata** — Added the `2026-10` changelog entry, updated the changelog index, and refreshed `static/releases.json`. — [fad7db117](https://github.com/commutatus/cm-admin/commit/fad7db117) ### v8.0.14 (2026-10-01) ## v8.0.14 **Released:** October 1, 2026 --- > ⚠️ **WARNING: Breaking Changes** > > The `deployment_alerts` cm-admin config accessor has been **removed**. Hatchbox deployment notifications are now controlled by the `DEPLOYMENT_ALERTS` environment variable. Any initializer that sets `config.deployment_alerts` will raise `NoMethodError` and must be updated. > 🔄 **WARNING: Migration Required** > > The `.hatchbox` deploy scripts are now fully environment-driven, so the `deploy-config/` directory checksum has changed. Sync the updated config and set the new environment variables before your next deploy. > > bash > bundle update cm-admin > rake cm_admin:deploy_config:sync > > > Then **commit the updated `deploy-config/` folder** before deploying — Hatchbox picks it up from git, not from a boot-time copy. Also set `APP_NAME` and `APP_URL` (and optionally `DEPLOYMENT_ALERTS=false`) in your Hatchbox environment variables. --- ### 🚀 Features _No feature changes in this release._ ### 🐛 Bug Fixes - 🩹 **Misconfigured environments no longer send broken deploy notifications** — Previously a missing app name or URL produced a Slack notification with a blank app name and broken buttons. The scripts now detect blank required values (`APP_NAME`, `APP_URL`, `SLACK_WEBHOOK_URL`) as soon as the config loads and post a single, explicit "config not available" notice naming the Hatchbox app, environment, and server, then exit cleanly. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - 🔧 **Hatchbox deploy scripts are now fully env-driven** — `app_name`, `app_url`, `env_name`, `slack_webhook`, `deployment_alerts?`, and the new `server_name` are read from environment variables instead of `CmAdmin.config`, `Rails.env`, or app credentials. This decouples the copied `.hatchbox` scripts from gem internals and removes their dependency on values like the `be_url` credential and `project_name`. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) - 🐚 **Deploy hooks run under plain `ruby`** — Hook scripts no longer boot Rails, and they source `~/.asdf-vars` when present so `HATCHBOX_SERVER_NAME` / `HATCHBOX_SERVER_ID` are available per Hatchbox docs. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) - ⏱️ **HTTP timeouts on the Slack POST** — Open/read timeouts were added so a hung webhook can no longer stall a deploy step. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) - 💬 **Clearer config-unavailable notification** — The notice now includes environment and server lines plus an **Environment Variables** button that deep-links to the app's Hatchbox env-vars page (`https://hatchbox.io/apps//env_vars`). — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) - 🪵 **Simplified notification script error handling and logging** — Reduced branching and clearer log output in `hatchbox_notification.rb`. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) - 🧹 **Removed duplicate deployment config directory** — Cleaned up a redundant `deploy-config/` copy in the gem source. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) ### ⚠️ Breaking Changes - 💥 **`CmAdmin.config.deployment_alerts` removed** — The toggle is now the `DEPLOYMENT_ALERTS` environment variable. Remove the accessor from your initializer: ruby # config/initializers/cm_admin.rb — DELETE THIS CmAdmin.configure do |config| config.deployment_alerts = false end and instead set it in your Hatchbox environment variables: DEPLOYMENT_ALERTS=false — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) ### 📚 Documentation - 📘 **`4. Deployment/HatchboxScripts.md` rewritten** — Documents the new environment-variable table (`APP_NAME`, `APP_URL`, `SLACK_WEBHOOK_URL`, `HATCHBOX_APP_ID` + `LOG_ID`, `HATCHBOX_SERVER_NAME`, `RAILS_ENV`, `DEPLOYMENT_ALERTS`), the env-driven `lib/config.rb`, the **Config Not Available** flow, the updated `ruby`-based file layout, and how to skip notifications via `DEPLOYMENT_ALERTS=false`. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) - 📘 **`3. Features/CmAdminConfiguration.md` updated** — Removed the `deployment_alerts` config row and its section, replacing it with a pointer to the `DEPLOYMENT_ALERTS` environment variable. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32)) - 📝 **Changelog and release metadata** — Added the `2026-10` changelog entry, updated the changelog index, and refreshed `static/releases.json`. — [d5b1f948d](https://github.com/commutatus/cm-admin/commit/d5b1f948d) ### v8.0.13 (2026-10-01) ## Catalyst v8.0.13 — October 1, 2026 ### 🚀 Features - ⚡ **Lazy-loaded dashboard widgets via Turbo Frames** — Dashboard pages now render instantly with skeleton placeholders, and each widget (chart, metric card, table) fetches its content asynchronously from a dedicated endpoint (`GET /:dashboard_name/widget/:widget_index`). Adds a `cm_dashboard_widget` controller action, `collect_dashboard_widgets` / `collect_section_widgets` / `widget_name` view helpers, a new `_dashboard_widget` partial, and a `.cm-dashboard-skeleton` pulse animation. Section widgets on show pages remain backward compatible. [#1579](https://github.com/commutatus/cm-admin/pull/1579) ([@austinjb32](https://github.com/austinjb32)) ![Dashboard widgets loading with skeleton placeholders](https://github.com/user-attachments/assets/079e7ee3-4ae6-4e96-a189-83499e71bf61) ### 🐛 Bug Fixes - 🧾 **Release-notes generation** — Raised the OpenRouter output budget and capped per-section prompt input (git log, PR bodies, docs diff) so large releases no longer truncate the JSON response and silently fall back to the raw git log. [#1620](https://github.com/commutatus/cm-admin/pull/1620) ([@austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - 💬 **Slack release notifications** — Restructured the release announcement into a Block Kit layout: a `✨ Highlights` paragraph, an always-present `⚠️ Breaking Changes` section, a `📋 Details` bullet list with PR links, renamed action buttons, and a release-date footer. [#1622](https://github.com/commutatus/cm-admin/pull/1622) ([@austinjb32](https://github.com/austinjb32)) ### ⚠️ Breaking Changes _No breaking changes in this release._ ### 📚 Documentation - 📝 Updated the changelog with the v8.0.12 release notes. [02a809040](https://github.com/commutatus/cm-admin/commit/02a809040) --- ## Release Notes — November 2026 ### v9.0.1 (2026-11-06) ## Catalyst v9.0.1 — 2026-11-06 🔄 **WARNING: Migration Required** — The optional User Groups feature needs its tables created before use. Installations that do not enable User Groups do not need to run these commands. ```sh bin/rails generate cm_admin:install_user_groups bin/rails db:migrate ``` ### 🚀 Features - 🚀 Added optional User Groups support for role selection on user forms. Admins can group roles, and user form role dropdowns can filter to the selected group. The feature is disabled by default. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### 🐛 Bug Fixes - 🐛 Fixed switch fields so string values such as `"1"` and `"true"` render as checked. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) - 🐛 Corrected the unique constraint syntax in the User Groups migration. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### ✨ Improvements - ✨ Added the `cm_admin:install_user_groups` generator to create the User Groups tables and seed the default setting when opting into the feature. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan)) ### ⚠️ Breaking Changes No breaking changes in this release. ### 📚 Documentation - 📚 Renumbered the platform-settings upgrade guide from v8.6.0 to v9.0.0 and updated the upgrade-guide index. [#1825](https://github.com/commutatus/cm-admin/pull/1825) ([#austinjb32](https://github.com/austinjb32)) - 📚 Expanded contributor and agent guidance with the local lint and GitHub signoff process, contribution workflow, and Stylelint instructions. [#1800](https://github.com/commutatus/cm-admin/pull/1800) ([#app/devin-ai-integration](https://github.com/app/devin-ai-integration)) - 📚 Added the v9.0.0 entry to the November 2026 changelog and updated the release registry. [94b23459a](https://github.com/commutatus/cm-admin/commit/94b23459a) ### v9.0.0 (2026-11-05) ## Catalyst v9.0.0 — 2026-11-05 > ⚠️ **WARNING: Breaking Changes** — Project settings are now separate from platform settings, and `cm_custom_method` has been renamed to `cm_custom_action`. Move project values into `config/application.rb` and update affected references. No database migration or rake task is required. ### 🚀 Features - 🚀 Adopted `ar_lazy_preload` for admin index and filtering associations. Associations are batch-loaded on first access rather than eagerly at query time, while preserving N+1 protection. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32)) ### 🐛 Bug Fixes - 🐛 Regenerated stale root and Rails 8.0/8.1 appraisal lockfiles so frozen Bundler installs can resolve the current gem version and dependencies. [#1823](https://github.com/commutatus/cm-admin/pull/1823) ([#austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - ✨ Platform settings now use a `Rails.cache`-backed reader, with cache invalidation on changes so updates apply across processes without a restart. Missing settings raise `CmAdmin::PlatformSettings::NotFound`, and `fetch` supports explicit fallbacks. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) - 🛡️ Reworked MCP authentication and tool registration around a per-request Rails controller flow and class-based `MCP::Tool` tools. Authorization now fails closed when a policy scope is missing. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - ✨ MCP tools now set the current user's permissions before each tool execution, keeping permission setup with authorization logic. [#1824](https://github.com/commutatus/cm-admin/pull/1824) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - ♻️ Refactored resource-controller request handling into declarative action services and shared helpers, making response behavior more explicit and testable. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) [#1801](https://github.com/commutatus/cm-admin/pull/1801) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes - ⚠️ Project settings are no longer overridden by platform-setting rows. Move any admin-managed project name, slug, logo URL, favicon URL, or default email into `config/application.rb`. The old `PLATFORM_SETTINGS` constant is deprecated; file settings now return a path string rather than a lambda, and missing slugs raise an error instead of returning `nil`. Replace `cm_custom_method` references with `cm_custom_action`. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) To preserve project values, set the relevant values in `config/application.rb`, for example: ```ruby config.x.project_settings.name = "Acme" config.x.project_settings.slug = "acme" config.x.project_settings.logo_url = "https://cdn.example.com/logo.png" config.x.project_settings.favicon_url = "https://cdn.example.com/favicon.png" config.x.project_settings.default_from_email = "no-reply@example.com" ``` If legacy project-setting rows exist, remove them from a Rails console: ```ruby CmPlatformSetting.where(slug: %w[project-name project-slug logo-url favicon-url default-email]).destroy_all ``` Replace `PLATFORM_SETTINGS[...]` with `CmAdmin::PlatformSettings[...]` or `.fetch(slug, default)` where a fallback is expected. Remove `.call` from file-setting access, and remove calls to `CmPlatformSetting.sync_project_settings_to_config`. No database migration or rake task is required. ### 📚 Documentation - 📚 Updated the DSL guide to explain lazy association preloading and the optional `ArLazyPreload.config.auto_preload = true` initializer setting. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32)) - 📚 Updated MCP documentation with the controller-based authentication and tool architecture, Rails console tool usage, and the fields returned by list and get tools. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 📚 Added platform-settings guidance covering the distinction between project and platform settings, cache behavior, lookup methods, fallbacks, and file-setting paths. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) - 📚 Updated auditing documentation to use the renamed `cm_custom_action` method. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### v8.5.4 (2026-11-05) ## Catalyst v8.5.4 — 2026-11-05 > ⚠️ **WARNING: Breaking Changes** — Platform settings are now separate from project settings, and `cm_custom_method` has been renamed to `cm_custom_action`. Move project values into `config/application.rb`, update references to the renamed action and deprecated settings API, and remove obsolete project-setting rows as described below. No database migration or rake task is required. ### 🚀 Features - 🚀 Adopted `ar_lazy_preload` for admin index and filtering associations. Associations are batch-loaded on first access rather than eagerly at query time, while preserving N+1 protection. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32)) ### 🐛 Bug Fixes - 🐛 Regenerated stale root and Rails 8.0/8.1 appraisal lockfiles so frozen Bundler installs can resolve the current gem version and dependencies. [#1823](https://github.com/commutatus/cm-admin/pull/1823) ([#austinjb32](https://github.com/austinjb32)) ### ✨ Improvements - ✨ Platform settings now use a `Rails.cache`-backed reader, with cache invalidation on changes so updates apply across processes without a restart. Missing settings raise `CmAdmin::PlatformSettings::NotFound`, and `fetch` supports explicit fallbacks. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) - 🛡️ Reworked MCP authentication and tool registration around a per-request Rails controller flow and class-based `MCP::Tool` tools. Authorization now fails closed when a policy scope is missing. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - ✨ MCP tools now set the current user's permissions before each tool execution, keeping permission setup with authorization logic. [#1824](https://github.com/commutatus/cm-admin/pull/1824) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - ♻️ Refactored resource-controller request handling into declarative action services and shared helpers, making response behavior more explicit and testable. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) [#1801](https://github.com/commutatus/cm-admin/pull/1801) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes - ⚠️ Project settings are no longer overridden by platform-setting rows. Move any admin-managed project name, slug, logo URL, favicon URL, or default email into `config/application.rb`. The old `PLATFORM_SETTINGS` constant is deprecated; file settings now return a path string rather than a lambda, and missing slugs raise an error instead of returning `nil`. Replace `cm_custom_method` references with `cm_custom_action`. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) To preserve project values, set the relevant values in `config/application.rb`, for example: ```ruby config.x.project_settings.name = "Acme" config.x.project_settings.slug = "acme" config.x.project_settings.logo_url = "https://cdn.example.com/logo.png" config.x.project_settings.favicon_url = "https://cdn.example.com/favicon.png" config.x.project_settings.default_from_email = "no-reply@example.com" ``` If the legacy rows exist, remove them from a Rails console: ```ruby CmPlatformSetting.where(slug: %w[project-name project-slug logo-url favicon-url default-email]).destroy_all ``` Replace `PLATFORM_SETTINGS[...]` with `CmAdmin::PlatformSettings[...]` or `.fetch(slug, default)` where a fallback is expected. Remove `.call` from file-setting access, and remove calls to `CmPlatformSetting.sync_project_settings_to_config`. No migration or rake command is required. ### 📚 Documentation - 📚 Updated the DSL guide to explain lazy association preloading and the optional `ArLazyPreload.config.auto_preload = true` initializer setting. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32)) - 📚 Updated MCP documentation with the controller-based authentication and tool architecture, Rails console tool usage, and the fields returned by list and get tools. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141)) - 📚 Added platform-settings guidance covering the distinction between project and platform settings, cache behavior, lookup methods, fallbacks, and file-setting paths. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) - 📚 Updated auditing documentation to use the renamed `cm_custom_action` method. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) ### v8.5.3 (2026-11-04) ## Catalyst v8.5.3 — 2026-11-04 ### 🚀 Features - 🚀 File fields now support Active Storage direct uploads for `single_file_upload` and `multi_file_upload`. Files upload directly to the storage service, with per-file progress feedback, while preserving normal form-upload behavior when direct uploads are unavailable. [#1814](https://github.com/commutatus/cm-admin/pull/1814) ([#austinjb32](https://github.com/austinjb32)) ### 🐛 Bug Fixes - 🐛 Restored appends to the engine’s `eager_load_paths` in the initializer. [dfc4635](https://github.com/commutatus/cm-admin/commit/dfc46355b) ### ✨ Improvements No additional improvements in this release. ### ⚠️ Breaking Changes No breaking changes in this release. ### 📚 Documentation - 📚 Updated review-app deployment guidance to note that the Tigris bucket needs CORS rules for browser-based direct uploads, and updated the November changelog. [9ebd550](https://github.com/commutatus/cm-admin/commit/9ebd550de) ### v8.5.2 (2026-11-04) ## Catalyst v8.5.2 — 2026-11-04 ### 🚀 Features - 🚀 Large index pages can use PostgreSQL planner row estimates instead of running an exact count query. Estimated totals display a `+` suffix, and `fast_count` is available on ActiveRecord models and relations. [#1767](https://github.com/commutatus/cm-admin/pull/1767) ([#austinjb32](https://github.com/austinjb32)) - 🚀 File fields now support Active Storage direct uploads, with per-file progress and previews, so large files can upload outside the form submission request. [#1745](https://github.com/commutatus/cm-admin/pull/1745) ([#austinjb32](https://github.com/austinjb32)) ### 🐛 Bug Fixes - 🐛 Aborted direct uploads now reject with `AbortError` and prevent pending requests from being sent, avoiding uploads that otherwise remained unsettled or continued after cancellation. [#1813](https://github.com/commutatus/cm-admin/pull/1813) ([#mikevic](https://github.com/mikevic)) - 🐛 Required single- and multi-file upload fields now validate correctly when no file is selected, including nested form fields. [#1808](https://github.com/commutatus/cm-admin/pull/1808) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) - 🐛 Improved direct-upload handling across review-app domains and chat uploads, including URL resolution and upload guards. [1bcc808](https://github.com/commutatus/cm-admin/commit/1bcc80872) · [d6583d7](https://github.com/commutatus/cm-admin/commit/d6583d77a) · [b38d8e8](https://github.com/commutatus/cm-admin/commit/b38d8e867) - 🐛 Added a tooltip during form submission. [f3cea83](https://github.com/commutatus/cm-admin/commit/f3cea8324) ### ✨ Improvements - ✨ Updated Bootstrap JavaScript, Popper, and vendored SCSS to compatible Bootstrap 5.3.8 versions, retaining Catalyst’s existing SCSS customizations. [#1804](https://github.com/commutatus/cm-admin/pull/1804) ([#mikevic](https://github.com/mikevic)) - ✨ File uploads now block form submission while uploading and are aborted when the drawer closes. [77c65d5](https://github.com/commutatus/cm-admin/commit/77c65d5bb) ### ⚠️ Breaking Changes No breaking changes in this release. ### 📚 Documentation - 📚 Documented the `count_threshold` pagination setting and `fast_count`, including configuration examples, PostgreSQL behavior, and how to disable estimates. [#1767](https://github.com/commutatus/cm-admin/pull/1767) ([#austinjb32](https://github.com/austinjb32)) - 📚 Updated the November changelog with the v8.5.1 release notes. [24f1883](https://github.com/commutatus/cm-admin/commit/24f188323) ### v8.5.1 (2026-11-02) ## Catalyst v8.5.1 — 2026-11-02 ### 🚀 Features - 🚀 Added a `:string` filter for text columns, with `is`, `is_not`, `contains`, `does_not_contain`, `is_empty`, and `is_not_empty` conditions. String matches are case-insensitive, treat wildcard characters literally, and support custom filter scopes. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky)) ### 🐛 Bug Fixes - 🐛 Corrected default-value handling for `:range` filters and made empty dropdown selections behave as no filter. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky)) ### ✨ Improvements - ✨ Standardized filter popup behavior: multi-select, range, and string filters now apply changes with the Apply button, Enter, or when the popup closes. Clear and Apply actions are shared across these popups. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky)) - ✨ String filter conditions handle empty strings and `NULL` rows as appropriate, with parameterized, case-insensitive matching. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky)) ### ⚠️ Breaking Changes No breaking changes in this release. ### 📚 Documentation - 📚 Updated the filter DSL reference and Advanced Filters and Custom Filter Method guides to document the `:string` type, its supported conditions, and custom-scope usage. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky)) ### v8.5.0 (2026-11-02) ## Catalyst v8.5.0 — 2026-11-02 > ⚠️ **WARNING: Breaking Changes** — API token status is now derived from `expires_at`, and the `api_tokens.status` database column is removed. Generate and run the migration, and update custom `ApiToken` models to derive status from `expires_at`. > > 🔄 **WARNING: Migration Required** — Run these commands to generate and apply the migration: > ```bash > rails generate cm_admin:remove_status_from_api_token > rails db:migrate > ``` ### 🚀 Features - 🔐 API tokens now respect `expires_at` for authentication and are reported as expired as soon as their expiry passes. Admins can set or extend token validity and expire live tokens from the API Token page. [#1715](https://github.com/commutatus/cm-admin/pull/1715) ([#anbublacky](https://github.com/anbublacky)) ### 🐛 Bug Fixes - 🛡️ Token verification now rejects tokens whose expiry has passed, without depending on a background task to update a stored status. [#1715](https://github.com/commutatus/cm-admin/pull/1715) ([#anbublacky](https://github.com/anbublacky)) ### ✨ Improvements - ✨ `expires_at` is now the single source of truth for token expiry. The `status`, `live?`, `expired?`, `live` scope, and `expired` scope are derived from it, keeping model behavior and database queries consistent. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic)) - ✨ The API Token “Expire” action now updates only `expires_at`; expiry no longer needs a status-sync rake task or callbacks. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic)) ### ⚠️ Breaking Changes - ⚠️ Removed the `api_tokens.status` column. Use the `cm_admin:remove_status_from_api_token` generator, apply its migration, and update any custom `ApiToken` model to derive status and expiry scopes from `expires_at`. The migration preserves manually expired tokens before dropping the column. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic)) ### 📚 Documentation - 📚 Updated the MCP setup example to omit the removed `status` column and use an expiry-based `live` scope. Added generator documentation for removing API token status, including migration behavior and the model changes apps need to make. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic)) ### v8.4.3 (2026-11-02) ## Catalyst v8.4.3 — 2026-11-02 ### 🚀 Features - 🚀 Added job listings and an application flow with job-specific questions, answer validation, and safeguards against duplicate applications. [#1779](https://github.com/commutatus/cm-admin/pull/1779) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) - 🚀 Added selected-record counts and refreshed bulk-action controls on index and associated-index tables, with counts updating as selections change. [#1753](https://github.com/commutatus/cm-admin/pull/1753) ([#anbublacky](https://github.com/anbublacky)) ### 🐛 Bug Fixes - 🐛 Bulk-action modals now show success and error banners together for partial success. Error details are escaped, failure counts are accurate, and custom error bodies replace the default message as intended. [#1718](https://github.com/commutatus/cm-admin/pull/1718) ([#anbublacky](https://github.com/anbublacky)) - 🐛 Fixed fresh Rails 8.1 PostgreSQL app installation: generated migrations now create the expected tables with unique versions, and the installer completes database setup before copying the initializer. [#1754](https://github.com/commutatus/cm-admin/pull/1754) ([#mikevic](https://github.com/mikevic)) - 🐛 Fixed filter dropdown errors by pinning Popper to its production ESM build and removing the global `window.process` shim. [#1798](https://github.com/commutatus/cm-admin/pull/1798) ([#mikevic](https://github.com/mikevic)) ### ✨ Improvements - 🛡️ Permission scopes are now enforced for record mutations as well as reads. Out-of-scope records cannot be changed through member actions, and create/update operations are checked against the applicable scope. [#1775](https://github.com/commutatus/cm-admin/pull/1775) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) - ✨ Added the `ct_path` helper to centralize per-model admin route generation and replace repeated dynamic route-helper calls. [#1799](https://github.com/commutatus/cm-admin/pull/1799) ([#mikevic](https://github.com/mikevic)) - ✨ Consolidated repeated controller model lookups into a memoized helper without changing behavior. [#1797](https://github.com/commutatus/cm-admin/pull/1797) ([#mikevic](https://github.com/mikevic)) - ✨ Aligned test and review-app Ruby versions with the repository’s Ruby version file, updated the test workflow’s Node version source, and upgraded GitHub cache actions to v6. [#1773](https://github.com/commutatus/cm-admin/pull/1773) ([#mikevic](https://github.com/mikevic)) [#1790](https://github.com/commutatus/cm-admin/pull/1790) ([#mikevic](https://github.com/mikevic)) [#1789](https://github.com/commutatus/cm-admin/pull/1789) ([#mikevic](https://github.com/mikevic)) - ✨ Added Claude Code session setup and edit-formatting hooks, and a command allowlist to make cloud development and routine checks easier. [#1791](https://github.com/commutatus/cm-admin/pull/1791) ([#mikevic](https://github.com/mikevic)) [#1793](https://github.com/commutatus/cm-admin/pull/1793) ([#mikevic](https://github.com/mikevic)) - ✨ Added `bin/ci` to run local lint checks and support the required signoff process. [#1755](https://github.com/commutatus/cm-admin/pull/1755) ([#BrijeshSajeev](https://github.com/BrijeshSajeev)) ### ⚠️ Breaking Changes _No breaking changes._ ### 📚 Documentation - 📚 Added a repository setup and testing guide for agents, documented signoff and linting steps, corrected stale workflow and spec references, and added a `CLAUDE.md` pointer to `AGENTS.md`. [#1772](https://github.com/commutatus/cm-admin/pull/1772) ([#git-pranavr](https://github.com/git-pranavr)) [#1785](https://github.com/commutatus/cm-admin/pull/1785) ([#mikevic](https://github.com/mikevic)) [#1786](https://github.com/commutatus/cm-admin/pull/1786) ([#mikevic](https://github.com/mikevic)) [#1784](https://github.com/commutatus/cm-admin/pull/1784) ([#mikevic](https://github.com/mikevic)) [#1792](https://github.com/commutatus/cm-admin/pull/1792) ([#mikevic](https://github.com/mikevic)) - 📚 Updated the installer documentation to list the tables it creates, and updated the October 2026 changelog with the v8.4.2 release notes. [#1754](https://github.com/commutatus/cm-admin/pull/1754) ([#mikevic](https://github.com/mikevic)) [decf0f8c0](https://github.com/commutatus/cm-admin/commit/decf0f8c0) --- ## Changelog Monthly release notes for Catalyst gem. Each page covers releases within that month, with AI-generated summaries of features, bug fixes, and improvements. ### Recent Months - [November 2026](./Changelog/2026-11) - [October 2026](./Changelog/2026-10) - [September 2026](./Changelog/2026-09) --- ## CM Admin Generators CM Admin provides a set of Rails generators to help you quickly set up and extend your admin panel functionality. ### Overview Generators automate the setup process by creating necessary migrations, configurations, and boilerplate code. They save time and ensure consistency across projects. ### Quick Reference | Command | Description | | -------------------------------------------------------------- | --------------------------------------------------------------------------- | | `rails generate cm_admin:install` | Initial setup - creates migrations, initializer, and mounts the engine | | `rails generate cm_admin:install_role` | Sets up role-based access control with permissions | | `rails generate cm_admin:add_authentication` | Adds Devise authentication if no auth system exists | | `rails generate cm_admin:add_graphql` | Installs GraphQL API with types, mutations, and queries | | `rails generate cm_admin:add_scheduler` | Creates cron job and job log tables for scheduled tasks | | `rails generate cm_admin:add_comments_with_mentions` | Adds commenting system with @mentions support | | `rails generate cm_admin:add_cm_support_ticket` | Sets up support ticket system for customer requests | | `rails generate cm_admin:add_cm_platform_setting` | Creates platform settings table for app configuration | | `rails generate cm_admin:add_cm_index_preference` | Adds user-customizable column preferences for lists | | `rails generate cm_admin:add_cron_job_notifications` | Creates notification system for cron job alerts | | `rails generate cm_admin:add_prompts_with_constants` | Adds Lexxy rich text templates with category support | | `rails generate cm_admin:add_token_type_to_api_token` | Migration to add token types to API tokens | | `rails generate cm_admin:add_name_to_api_token` | Migration to add a name field to API tokens | | `rails generate cm_admin:remove_status_from_api_token` | Drops api_tokens.status; expiry is derived from expires_at | | `rails generate cm_admin:add_unique_index_to_permissions` | Migration to prevent duplicate permissions | | `rails generate cm_admin:file_import_improvement` | Migration to add tracking columns to file imports | | `rails generate cm_admin:file_export_improvement` | Migration to upgrade file export functionality | | `rails generate cm_admin:add_audit_metadata_to_versions` | Adds action_type and action_name columns to PaperTrail versions | | `rails generate cm_admin:add_otp_requests` | Creates otp_requests table for OTP-based authentication | | `rails generate cm_admin:add_email_logs` | Creates the cm_email_logs table for recording CmAdmin.send_email deliveries | | `rails generate cm_admin:rename_file_import_and_export_tables` | Rename file_imports and file_exports tables | | `rails generate cm_admin:add_webhook_events` | Creates the cm_webhook_events table for webhook logging | | `rails generate cm_admin:add_webhook_event_recipient` | Adds recipient link and original_link columns to cm_webhook_events | | `rails generate cm_admin:ct_page_view` | Creates ct_page_views table for Saved Views (replaces index preferences) | | `rails generate cm_admin:add_bot_conversations` | Creates the tables for the Catalyst AI (CT Bot) chat feature | | `rails generate cm_admin:add_bot_transcriptions` | Creates the ct_bot_transcriptions table for Catalyst AI voice input | --- ### Core Generators #### 1. Install Generator **Command:** ```bash rails generate cm_admin:install ``` **What it does:** - Installs Action Text for rich text editing - Creates the CM Admin initializer (`config/initializers/cm_admin.rb`) - Sets up custom JavaScript and CSS files - Creates the application policy for authorization - Mounts the CM Admin engine in routes - Creates migrations for the `ct_file_imports`, `ct_file_exports` and `cm_index_preferences` tables - Installs i18n ActiveRecord for translations - Runs database migrations **Why you need it:** This is the **essential first step** when setting up CM Admin. It creates the foundation that all other features depend on. --- #### 2. Install Role Generator **Command:** ```bash rails generate cm_admin:install_role ``` **What it does:** - Creates migration for CmRole (roles table) - Creates migration for CmPermission (permissions table) - Creates a default "Admin" role - Automatically assigns all available permissions to the Admin role for all configured models **Why you need it:** Enable **role-based access control (RBAC)** in your admin panel. This allows you to define different user roles with specific permissions. --- #### 3. Add Authentication Generator **Command:** ```bash rails generate cm_admin:add_authentication ``` **What it does:** - Adds Devise gem to your Gemfile - Installs and configures Devise - Prompts for the user model name (defaults to "user") - Creates the user model with Devise - Sets up application controller with authentication - Creates authentication concern - Creates Current model for thread-safe context - Adds `super_admin?` method to the user model **Why you need it:** Use this when your application **doesn't have an authentication system** yet. It quickly sets up Devise-based authentication with CM Admin integration. --- ### Feature Generators #### 4. Add GraphQL Generator **Command:** ```bash rails generate cm_admin:add_graphql ``` **What it does:** - Adds GraphQL-related gems (graphql, graphql-errors, graphql-rails_logger) - Installs GraphQL Rails - Creates GraphQL schema - Sets up type directories (inputs, enums, objects) - Creates base mutation and query classes - Sets up model concerns - Creates exception handling **Why you need it:** Enable **GraphQL API** for your application. Useful when you need a flexible API for external integrations or modern frontend frameworks. --- #### 5. Add Scheduler Generator **Command:** ```bash rails generate cm_admin:add_scheduler ``` **What it does:** - Creates migration for CmCronJob (scheduled jobs) - Creates migration for CmCronJobLog (job execution logs) **Next steps:** - Add `CmCronJob, CmCronJobLog` to `config.included_models` in `cm_admin.rb` - Run `rails db:migrate` - Start Sidekiq for background job processing **Why you need it:** Add **cron job management** to your admin panel. Schedule and monitor recurring background jobs directly from the admin interface. --- #### 6. Add Comments with Mentions Generator **Command:** ```bash rails generate cm_admin:add_comments_with_mentions ``` **What it does:** - Creates migration for CmComment (comments table) - Creates migration for CmUserMentions (user mentions in comments) **Next steps:** - Run `rails db:migrate` **Why you need it:** Enable **commenting with @mentions** functionality. Users can comment on records and tag other users with @mentions for notifications. --- #### 7. Add CM Support Ticket Generator **Command:** ```bash rails generate cm_admin:add_cm_support_ticket ``` **What it does:** - Creates migration for CmSupportTicket - Creates migration for CmSupportTicketEmployee (ticket assignments) - Creates migration adding created_by to CmComment **Next steps:** - Add `CmSupportTicket` to `config.included_models` in `cm_admin.rb` - Run `rails db:migrate` **Why you need it:** Set up a **support ticket system** within your admin panel. Manage customer support requests and assign them to team members. --- #### 8. Add CM Platform Setting Generator **Command:** ```bash rails generate cm_admin:add_cm_platform_setting ``` **What it does:** - Creates migration for CmPlatformSetting (platform-wide settings) **Next steps:** - Run `rails db:migrate` **Why you need it:** Add **platform settings management** to your admin panel. Store and manage application-wide configuration values. --- #### 9. Add CM Index Preference Generator **Command:** ```bash rails generate cm_admin:add_cm_index_preference ``` **What it does:** - Creates migration for CmIndexPreferences (user column preferences) - Creates constants initializer **Next steps:** - Run `rails db:migrate` **Why you need it:** Allow users to **customize which columns** appear in list views. Each user can save their preferred column visibility and order. --- #### 10. Add Cron Job Notifications Generator **Command:** ```bash rails generate cm_admin:add_cron_job_notifications ``` **What it does:** - Creates migration for CmNotifiable (notification system) **Next steps:** - Run `rails db:migrate` - Add to your models: ```ruby has_many :notifiables, as: :notifiable, dependent: :destroy accepts_nested_attributes_for :notifiables, allow_destroy: true ``` **Why you need it:** Enable **unified notification system** for cron jobs. Send email and webhook notifications when scheduled jobs complete or fail. --- #### 11. Add Prompts with Constants Generator **Command:** ```bash rails generate cm_admin:add_prompts_with_constants ``` **What it does:** - Creates migration for CmPrompt (rich text templates) - Creates migration for ConstantMap (polymorphic associations) - Adds Lexxy template platform setting - Enables AI-powered slash commands in rich text editors **Next steps:** - Add `CmPrompt` to `config.included_models` in `cm_admin.rb` - Run `rails db:migrate` - Ensure Lexxy gem is installed (~> 0.8) **Why you need it:** Add **AI-powered rich text templates** to your admin panel. Users can create reusable prompt templates with category-based filtering and access them via slash commands (`/`) in any rich text field. --- ### Migration Generators (Upgrades) #### 12. Add Token Type to API Token Generator **Command:** ```bash rails generate cm_admin:add_token_type_to_api_token ``` **What it does:** - Creates migration to add token_type column to API tokens **Why you need it:** **Upgrade existing installations** to support different token types (e.g., read-only, read-write) for API authentication. --- #### 13. Add Unique Index to Permissions Generator **Command:** ```bash rails generate cm_admin:add_unique_index_to_permissions ``` **What it does:** - Creates migration to add unique index on cm_permissions table **Why you need it:** **Database integrity upgrade** to prevent duplicate permission entries. --- #### 14. File Import Improvement Generator **Command:** ```bash rails generate cm_admin:file_import_improvement ``` **What it does:** - Creates migration to add `action_name`, `importer_class_name`, `import_type`, and `associated_model_id` columns to the file_imports table **Next steps:** - Run `rails db:migrate` **Why you need it:** **Upgrade existing installations** to store import metadata directly on the file import record. This enables the job processor to work without relying on runtime model lookups and provides better traceability in the file imports index and show views. --- #### 15. File Export Improvement Generator **Command:** ```bash rails generate cm_admin:file_export_improvement ``` **What it does:** - Creates migration to improve file export functionality - Runs the migration automatically **Why you need it:** **Upgrade existing installations** to the improved file export system with better performance and features. --- #### 16. Add Audit Metadata to Versions Generator **Command:** ```bash rails generate cm_admin:add_audit_metadata_to_versions ``` **What it does:** - Creates a migration to add `action_type` and `action_name` columns to the PaperTrail `versions` table - Adds indexes on both columns for fast audit filtering - Checks that the `versions` table exists before generating the migration - Only adds columns that are not already present **Next steps:** - Run `rails db:migrate` **Why you need it:** **Upgrade existing installations** to record queryable audit metadata on every PaperTrail version. cm-admin uses these columns to store which admin action (CRUD, custom action, or bulk action) triggered a change. Required for the auditing feature described in [Auditing](./Features/Auditing). --- #### 17. Add OTP Requests Generator **Command:** ```bash rails generate cm_admin:add_otp_requests ``` **What it does:** - Creates migration for `otp_requests` table with columns: `otp`, `email` (citext), `expired_at`, `status`, `verified_at`, `user_id` (optional foreign key), and timestamps - Adds an index on the `email` column - Prints post-install instructions reminding you to add `has_many :otp_requests` and `include OtpAuthenticatable` to your User model **Next steps:** - Run `rails db:migrate` - Add to your User model: ```ruby has_many :otp_requests, dependent: :destroy include OtpAuthenticatable ``` - Enable in `cm_admin.rb`: ```ruby config.use_catalyst_otp = true config.auth_method = :otp # or :password, :custom ``` **Why you need it:** Set up **OTP-based authentication** in your admin panel. The `otp_requests` table stores one-time codes with expiry and status tracking. Supports both user-associated OTPs and email-only OTPs (for signup flows without a user record). See [OtpAndAuth](./Features/OtpAndAuth) for the full setup guide. #### 17. CT Page View Generator **Command:** ```bash rails generate cm_admin:ct_page_view ``` **What it does:** - Enables the PostgreSQL `citext` extension if needed - Drops the legacy `cm_index_preferences` table if it exists (data is not migrated) - Creates the `ct_page_views` table with: - `owner` as a required polymorphic reference - `name` (`citext`, required) - `ar_model_name`, `associated_ar_model_name`, `visible_columns`, `hidden_columns`, `records_per_page` - `is_default`, plus a partial unique index allowing one default view per owner / model - `view_type` (integer enum: table, card, kanban) - `filter_params` (`jsonb`) and `sort_params` (`json`), both defaulting to `{}` **Next steps:** - Run `rails db:migrate` **Why you need it:** Powers [Saved Views](./3.%20Features/SavedViews.md): per-user saved filters (including [Advanced Filters](./3.%20Features/AdvancedFilters.md)), sort, columns, page size, and view type. Replaces Index Preference. --- #### 18. Add Email Logs Generator **Command:** ```bash rails generate cm_admin:add_email_logs ``` **What it does:** - Creates the `cm_email_logs` table used to record every `CmAdmin.send_email` delivery when `config.enable_email_logging` is `true`. **Next steps:** - Run `rails db:migrate` **Why you need it:** Enable **email delivery logging** for your admin panel. This stores delivery status, recipients, failure reasons, and record associations, which is useful for auditing and debugging outgoing emails. See [CmAdminEmail](./Features/CmAdminEmail) for the full email sending API. --- #### 19. Rename File Import and Export Tables Generator **Command:** ```bash rails generate cm_admin:rename_file_import_and_export_tables ``` **What it does:** - Creates a reversible migration that renames `file_imports` to `ct_file_imports` and `file_exports` to `ct_file_exports` - Updates persisted model names from `FileImport` and `FileExport` to `CtFileImport` and `CtFileExport` in Active Storage, PaperTrail, permissions, and index preferences **Next steps:** - Run `rails db:migrate` - Replace application references to `FileImport` and `FileExport` with `CtFileImport` and `CtFileExport` **Why you need it:** Use this generator when upgrading an existing installation to the `CtFileImport` and `CtFileExport` model names. --- #### 20. Add Webhook Events Generator **Command:** ```bash rails generate cm_admin:add_webhook_events ``` **What it does:** - Creates the `cm_webhook_events` table used to log incoming webhook events when `config.enable_webhook_processing` is `true` - Adds columns for `cm_service` (gem-managed service enum, e.g. `postmark`), `host_service` (host-app service enum), `event_type`, `status`, `event_id`, `uuid` (auto-assigned via `SecureRandom`, unique index), `error_message`, `processed_at`, and a polymorphic `eventable` association - Adds indexes on status, both services, event type, event ID, UUID (unique), created date, the `eventable` association, and composite service + event ID + event type lookups **Next steps:** - Run `rails db:migrate` - Ensure ActiveStorage is installed and configured (`rails active_storage:install`) — webhook payloads are stored as `raw_payload` attachments **Why you need it:** Enable **webhook logging** for your admin panel. Incoming webhooks are persisted with their payload, status, and error information, and processed asynchronously via `ProcessWebhookEventJob`. See [CmAdminWebhooks](./Features/CmAdminWebhooks) for the full webhook setup guide. --- #### 21. Add Webhook Event Recipient Generator **Command:** ```bash rails generate cm_admin:add_webhook_event_recipient ``` **What it does:** - Adds a polymorphic `recipient` association (`recipient_type`/`recipient_id` + index) to `cm_webhook_events` — links each event to the recipient it belongs to, populated at webhook receipt by matching the payload's recipient email against the log's record - Adds an `original_link` column — for Click events, the clicked URL, denormalized at receipt so clicks can be grouped and counted in SQL **Next steps:** - Run `rails db:migrate` **Why you need it:** For installations that already ran `add_webhook_events`. Per-recipient open/click tracking (recipient columns, Click Details, Events tab, campaign engagement metrics) depends on these columns. The migration is idempotent — it skips columns that already exist, so it is safe to run more than once. --- #### 22. Add Description to API Token Generator **Command:** ```bash rails generate cm_admin:add_name_to_api_token ``` **What it does:** - Creates a migration to add a `name` string column to the `api_tokens` table - Backfills existing records with `'Unnamed Token'` so every token is identifiable - Drops the legacy `description` column if it exists (added by v8.2.14's `add_description_to_api_token`) **Next steps:** - Run `rails db:migrate` **Why you need it:** **Upgrade existing installations** to give every API token a mandatory, human-readable name (max 100 characters), making it easier to identify what each token is used for and to search tokens by name. --- #### 22. Add Bot Conversations Generator **Command:** ```bash rails generate cm_admin:add_bot_conversations ``` **What it does:** - Creates migration for `ct_bot_conversations` (chat conversations per user) - Creates migration for `ct_bot_messages` (user/assistant messages with rich text content and file attachments) - Creates migration for `ct_bot_artifacts` (interactive elements such as clarifying-question cards) - Creates migration for `ct_cost_trackings` (per-request token usage and cost) - Creates migration adding `is_catalyst_ai_enabled` to `cm_roles` (per-role opt-in flag) **Next steps:** - Add `CtBotConversation, CtBotMessage, CtCostTracking` to `config.included_models` in `zcm_admin.rb` - Run `rails db:migrate` - Add your OpenRouter key to credentials: `catalyst_ai.openrouter_api_key` - Ensure `config/cable.yml` exists (Redis adapter recommended in production) — replies stream over ActionCable - Declare `mcp_actions` on the models the assistant may query - Enable **Catalyst AI** on each `CmRole` that should see the widget **Why you need it:** Sets up **Catalyst AI (CT Bot)** — an in-app chat widget that answers admin users' questions using your MCP-enabled models. See [Catalyst AI](./3.%20Features/CatalystAi.md) for the full feature guide. --- #### 24. Add Bot Transcriptions Generator **Command:** ```bash rails generate cm_admin:add_bot_transcriptions ``` **What it does:** - Creates migration for `ct_bot_transcriptions` (voice recordings with their transcribed text, status and cost, optionally linked to a conversation and message) **Next steps:** - Add `CtBotTranscription` to `config.included_models` in `zcm_admin.rb` - Run `rails db:migrate` **Why you need it:** Enables **voice input** in the Catalyst AI chat widget (added in v8.4.1). Run it after `add_bot_conversations` — on existing Catalyst AI installs and fresh installs alike. See the [v8.4.1 upgrade guide](./5.%20Upgrade%20Guide/v8.4.1.md). --- #### 25. Remove Status from API Token Generator **Command:** ```bash rails generate cm_admin:remove_status_from_api_token ``` **What it does:** - Stamps `expires_at` with the current time on tokens stored as `expired` that would otherwise still be valid by date, so none are re-enabled - Drops the `status` column from `api_tokens` - The `down` migration re-adds `status` and marks tokens whose `expires_at` has passed as `expired` **Next steps:** - Replace `enum :status, %i[live expired]` in your `ApiToken` model with the derived scopes and methods (the cm-admin API Tokens pages call `status` and `live?`): ```ruby scope :live, -> { where(expires_at: [nil, Time.current..]) } scope :expired, -> { where(expires_at: ...Time.current) } def live? expires_at.nil? || expires_at.future? end def expired? !live? end def status live? ? "live" : "expired" end ``` - Run `rails db:migrate` **Why you need it:** Whether an API token is live is now derived only from `expires_at`. `ApiToken#status`, `#live?`, `#expired?` and the `live` / `expired` scopes are computed from it. --- ### Recommended Setup Order For new projects, run generators in this order: 1. `rails generate cm_admin:install` - Foundation setup 2. `rails generate cm_admin:add_authentication` - If no auth exists 3. `rails generate cm_admin:add_otp_requests` - For OTP-based auth 4. `rails generate cm_admin:install_role` - For role-based permissions 5. `rails generate cm_admin:file_import_improvement` - Add tracking columns to file imports 6. Feature generators as needed (scheduler, comments, support tickets, etc.) ### Troubleshooting **Missing models:** After running feature generators, remember to add the new models to `config.included_models` in `cm_admin.rb`. --- ## Has One Tab ### Overview The **Has One Tab** feature enables displaying a `has_one` associated record directly within a tab on a parent model's show page. Instead of a paginated list (used for `has_many`), it renders a single-record profile view with Edit, Delete, and custom action support. When the associated record does not exist yet, an **empty state** is shown with a button to create one. When the user lacks permission to view the record, a **permission denied** state is shown. --- ### How It Works Adding `relationship_type: :has_one` to a `tab` declaration changes the tab behaviour: | State | What Renders | | ----- | ------------ | | Record exists and user has access | Profile view with fields, Edit/Delete/custom actions | | Record does not exist | Empty state with an **Add** button that pre-fills the association | | User lacks permission (Pundit scope) | Permission denied message | The tab badge shows **0** or **1** instead of a record count. --- ### DSL Pass `relationship_type: :has_one` alongside `layout_type: 'cm_association_show'` in the `tab` declaration. #### Parameters | Name | Type | Required | Default | Description | | ------------------- | ------------------ | -------- | ------------ | ----------------------------------------------------------------------------- | | `tab_name` | `String \| Symbol` | Yes | — | Tab label shown in the navigation. | | `action_name` | `String` | Yes | — | Unique action name (used in routes and policy scopes). | | `associated_model` | `String` | Yes | — | The `has_one` association name on the parent model. | | `layout_type` | `String` | Yes | — | Must be `'cm_association_show'` for has-one tabs. | | `relationship_type` | `Symbol` | Yes | `:has_many` | Set to `:has_one` to enable single-record display. | | `show_count` | `Boolean` | No | `true` | Set to `false` to hide the badge, or leave `true` to show 0/1. | | `display_if` | `Proc` | No | `-> { true }` | Lambda returning a boolean to conditionally show the tab. | | `allow_create_action` | `Proc` | No | `-> { true }` | Lambda controlling whether the Add button appears on the empty state. | | `eager_load_associations` | `Array` | No | `nil` | Associations to eager-load when fetching the record. | #### Updated Method Signature ```ruby tab(tab_name, action_name, associated_model: nil, layout_type: nil, relationship_type: :has_many, show_count: true, display_if: nil, allow_create_action: -> { true }, eager_load_associations: nil, &block) ``` --- ### Example #### 1. Model Setup ```ruby class User < ApplicationRecord has_one :profile end class Profile < ApplicationRecord belongs_to :user end ``` #### 2. cm_admin Configuration ```ruby # app/models/cm_user.rb cm_admin do cm_show do tab :profile, 'profile', associated_model: 'profile', layout_type: 'cm_association_show', relationship_type: :has_one, show_count: false end end # app/models/cm_profile.rb cm_admin do cm_show do cm_section 'Personal Details' do field :first_name field :last_name field :bio end end end ``` #### 3. Permissions Add the action to the Pundit policy and policy scope for the associated model. For example: ```ruby # app/policies/cm_admin/profile_policy.rb class CmAdmin::ProfilePolicy < CmAdmin::ApplicationPolicy class ProfileScope < Scope def resolve scope.where(user: user) end end def show? = true def edit? = true def destroy? = true end ``` After adding the tab, update the permissions for the relevant roles in the **Permissions** tab inside cm_admin. --- ### Behaviour Details #### Empty State When no associated record exists, the tab renders an empty state with an **Add** button. Clicking the button opens the creation form for the associated model and pre-fills: - `associated_id` — the parent record's ID - `associated_class` — the parent model name - `foreign_key` — the foreign key column - `polymorphic_name` — set automatically for polymorphic associations #### Permission Denied State If a Pundit policy scope exists for the action and the record does not pass the scope check, the permission denied state is shown instead of the record. No record data is leaked. #### Tab Badge For `has_one` tabs, the badge displays **0** (no record) or **1** (record exists), instead of a full count query. #### Actions Bar The profile view renders an actions bar above the record fields with: - **Edit** button — links to the associated model's edit page with a `referrer` param so the user is returned to the parent show page after saving. - **Custom actions** dropdown — respects `display_type` (`:button`, `:modal`, `:form_modal`, `:link`). - **Delete** button — triggers the destroy modal; shown only when the user has permission. --- ### Comparison: `has_many` vs `has_one` Tab | Behaviour | `has_many` (default) | `has_one` | | ----------------------- | -------------------------------------- | -------------------------------------- | | `layout_type` | `cm_association_index` | `cm_association_show` | | `relationship_type` | `:has_many` (default) | `:has_one` | | Renders | Paginated table / card list | Single record profile view | | No record state | Empty table | Empty state with Add button | | Permission denied state | Standard Pundit scope on list | Dedicated permission denied view | | Tab badge | Record count | 0 or 1 | | `show_count` | Usually `true` | Usually `false` | --- ## EnumHelper 1. Create Enum from Rails Model Enum Use `enum_from_model` to build a GraphQL Enum based on a model's enum attribute. - first argument is ActiveRecord Model Name. - second argument is string and name of the enum on model. - An optional `graphql_enum_name:` can be provided to customize the GraphQL type name. NOTE: GraphQL enum names are automatically generated in the format `Enum` but can be customized. examples: ```ruby field :listing_type, Types::EnumHelpers.enum_from_model(Listing, 'listing_type'), null: true ``` ```ruby field :listing_type, Types::EnumHelpers.enum_from_model(Listing, 'listing_type', graphql_enum_name: 'MyCustomName'), null: true ``` 2. Create Enum from Array Use `enum_from_array` to build a GraphQL Enum from an array of values. - first argument is array of string containing enum values. - second argument is graphql name for this. ```ruby field :status, Types::EnumHelpers.enum_from_array(%w[draft published archived], 'StatusEnum'), null: true ``` --- ## Ideal GraphQL Configuration This guide shows the recommended setup for CM GraphQL using: - **[EnumHelper](./EnumHelper)** for reusable enum types in sort inputs and filters - **[PagingTypeHelper](./PagingTypeHelper)** for standardized list response types > In all examples below, replace `ModelName` with the model where you want this implemented. #### 1. Define input types for sort Use `EnumHelper` for sort direction and model-specific sortable columns. ```ruby module Types module Inputs class ModelNameSort < Types::BaseInputObject graphql_name 'ModelNameSortInput' argument :column, Types::EnumHelpers.enum_from_array(%w[created_at name], 'ModelNameSortColumn'), required: false argument :direction, Types::Enums::Base::SortDirection, required: false end end end ``` #### 2. Define input types for filters NOTE: Use `Types::Inputs::Base::Filter` as the base class for filter inputs, this already have `q` field for search and `ids` field for filtering by ids. ```ruby module Types module Inputs class ModelNameFilter < Types::Inputs::Base::Filter graphql_name 'ModelNameFilterInput' argument :some_fields, [String], required: false end end end ``` #### 3. Define the list query using PagingTypeHelper For list queries, use this structure: ```ruby module Queries module ModelName class List < Queries::BaseQuery description 'list records on a model' argument :filters, Types::Inputs::ModelNameFilter, required: false argument :sort, Types::Inputs::ModelNameSort, required: false argument :pagination, Types::Inputs::Base::Paging, required: false type Types::PagingTypeHelper.paging_type_for_model(model: ::ModelName), null: true def resolve(filters: nil, pagination: nil, sort: nil) per_page = pagination&.per_page || DEFAULT_PER_PAGE page = pagination&.page_no || 1 ::ModelName.filters(filters).order_by(sort&.column, sort&.direction).list(per_page, page) end end end end ``` #### 4. Add the query to your root `QueryType` ```ruby module Types class QueryType < Types::BaseObject field :model_names, resolver: Queries::ModelName::List, description: 'List ModelName records' end end ``` #### 5. On Model this methods and scopes are required - `filters` class method - for filtering records - `order_by` scope - for sorting records - fields filters scopes - for filtering by specific fields example: ```ruby class ModelName < ApplicationRecord scope :search_filter, lambda { |query| return all if query.blank? where('model_names.name ILIKE ?', "%#{query}%") } scope :ids_filter, lambda { |ids| return all if ids.blank? where(id: ids) } scope :order_by, lambda { |column, direction = 'asc'| return all if column.blank? order(column => direction) } scope :some_fields_filter, lambda { |some_fields| return all if some_fields.blank? where(some_field: some_fields) } def self.filters(filters) return all if filters.blank? all .search_filter(filters[:q]) .ids_filter(filters[:ids]) .some_fields_filter(filters[:some_fields]) end end ``` --- ## PagingTypeHelper ### Create Enum from Rails Model Enum Use `paging_type_for_model` to build a GraphQL Paging Type. Arguments: - `model`: ActiveRecord Model Name. - `record_data_type`: GraphQL Type for the record data. - `graphql_type_name:` can be provided to customize the GraphQL type name. NOTE: GraphQL Paging Type Name will be automatically generated in the format `PagingType` but can be customized. NOTE: If Record Data Type is not provided, it will use the object type in the format `Types::Objects::Type`. examples: ```ruby Types::PagingTypeHelper.paging_type_for_model(model: Course, record_data_type: Types::Objects::Course) ``` ```ruby Types::PagingTypeHelper.paging_type_for_model(model: Course, record_data_type: Types::Objects::Course, graphql_type_name: 'CustomCoursePagingType') ``` - To create a reusable Type create a initializer in `config/initializers/graphql_paging_type_autogen.rb` ```ruby Rails.application.config.to_prepare do Types::CoursePagingType = Types::PagingTypeHelper.paging_type_for_model(model: Course, record_data_type: Types::Objects::Course) end ``` --- ## RecaptchaExtension To use recaptcha extension in the graphql field, add the following line in the field: ```ruby field :login, mutation: Mutations::Auth::Login, extensions: [ CmGraphql::Extensions::RecaptchaExtension ] # With options field :login, mutation: Mutations::Auth::Login, extensions: [ { CmGraphql::Extensions::RecaptchaExtension => { action: 'login', minimum_score: 0.5 } } ] ``` ### Sample request ```ruby mutation Login { login(input: { email: "anbu+5@commutatus.com" }, recaptchaToken: "recaptcha_token") { accessToken expiresAt } } ``` ### Arguments: - `action`: Action name. It will be mandatory unless field_name and action passed are not same. For eg: login is the field_name, so the action option can be ignored. - `minimum_score`: Minimum score for recaptcha. It will be optional. If not provided, it will use the RECAPTCHA_MINIMUM_SCORE constant. ### Credentials Please create google recaptcha account from [google console](https://www.google.com/recaptcha/admin) Please update the credentials for recaptcha, format will be ```ruby gcp: recaptcha_site_key: 'your_site_key' recaptcha_secret_key: 'your_secret_key' ``` To override the minimum score for recaptcha instead of using options, add the following line in `config/initializers/constants.rb`: ```ruby RECAPTCHA_MINIMUM_SCORE = 0.5 ``` ### Note - RecaptchaExtension verfier module is derived from the gem [recaptcha](https://github.com/ambethia/recaptcha) --- ## CM GraphQL CM GraphQL is a companion library that provides GraphQL API support for Rails applications. ### Overview CM GraphQL provides helper utilities that simplify common GraphQL patterns in Rails applications, enabling flexible and efficient data querying for your frontend applications. ### Available Helpers - **[EnumHelper](./EnumHelper.md)** - Create GraphQL enums from Rails model enums or custom arrays - **[PagingTypeHelper](./PagingTypeHelper.md)** - Generate reusable pagination types for GraphQL queries