From cad89a6c23a839f1f1e5b9eabd540d3c67ebadfe Mon Sep 17 00:00:00 2001 From: Jason Sidoryn Date: Fri, 19 Sep 2025 12:45:53 +0930 Subject: [PATCH 1/4] docs: consolidate admin module workflow --- docs/admin-module-workflow.md | 170 +++++++++++++++++++++++++ docs/archive/koi-basic-module-guide.md | 131 +++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 docs/admin-module-workflow.md create mode 100644 docs/archive/koi-basic-module-guide.md diff --git a/docs/admin-module-workflow.md b/docs/admin-module-workflow.md new file mode 100644 index 00000000..b3f3791c --- /dev/null +++ b/docs/admin-module-workflow.md @@ -0,0 +1,170 @@ +# Koi Admin Module Workflow + +This playbook focuses on creating and maintaining admin modules with Koi’s generators. Use it when you need to scaffold a new CRUD surface, add fields after the fact, or tweak how results are presented inside the admin shell. + +## What You Get + +- An Active Record model with an `admin_search` scope and optional defaults from `koi:model`. +- An admin controller, request spec, GOV.UK-styled views, and wired routes from `koi:admin`. +- Automatic menu registration in `config/initializers/koi.rb` so the module appears once the server restarts. +- Headers, breadcrumbs, and tables that follow Koi’s conventions, including Turbo-compatible responses. + +## End-to-end Workflow + +1. **Model & migration** + - Run `bin/setup` once per clone so dependencies and dummy data are ready. + - For a brand-new table, prefer `bin/rails g koi:model Article title:string body:rich_text archived_at:datetime` so the model gains the `admin_search` scope and optional ordinal default scope automatically (`lib/generators/koi/model/model_generator.rb:9`). Attribute order here flows through to forms unless you override it later. + - Add any migrations you need (including `ordinal:integer` or `archived_at:datetime` if you want drag-and-drop ordering or archiving support). +2. **Run migrations** with `bin/rails db:migrate` before invoking `koi:admin`; otherwise you must pass every attribute manually. +3. **Generate the admin surface** + - `bin/rails g koi:admin Article` will invoke the controller, views, and route generators (`lib/generators/koi/admin/admin_generator.rb:9`). Passing attributes here lets you override field order; otherwise the migrated column order is used. + - By default the generator introspects existing columns, attachments, rich text associations, and belongs_to references if you skip explicit attribute arguments (`lib/generators/koi/helpers/attribute_helpers.rb:15`). + - Define a human-friendly `to_s` on the model (see **Pick a display attribute** below) so records render as titles across headers, breadcrumbs, and selects. + - Wrap the generated controller in `module Admin` (or move it under `module Admin` yourself) so it inherits the helper stack from `Admin::ApplicationController`; without this you’ll hit `undefined method actions_list` errors in the admin shell. + - Ensure `config/routes/admin.rb` and `config/initializers/koi.rb` exist (`bin/rails g koi:admin_route` will create them if missing) and restart the server once the generator finishes. +4. **Review navigation** – the route generator inserts your module into `config/initializers/koi.rb` under `Koi::Menu.modules` so it appears in the navigation dialog sorted alphabetically (`lib/generators/koi/admin_route/admin_route_generator.rb:46`). +5. **Wire authorisation/tests** after generation. The scaffolds don’t add policies; you supply them in the controller. +6. **Regenerate as needed** – when the table schema changes, re-run `bin/rails g koi:admin Article` (or rerun `koi:model`) to refresh forms, tables, and strong parameters. Generators ship with `--force` defaulting to true so they overwrite existing files; commit or stash your changes first so the diff is easy to reconcile. +7. **Smoke-test the UI** – sign in to `/admin`, create a record, view it, edit it, and delete it to confirm headings, helper methods, and navigation behave as expected. + +### Regeneration Tips + +- Use `--force=false` if you want to preview diffs without overwriting (`bin/rails g koi:admin Article --force=false`). +- Re-running `koi:admin_route` after you rename a module cleans up routes and the menu entry. +- If you have extensive manual edits to generated templates, consider extracting shared partials/components so future regenerations only touch wrapper code. +- Each generator defaults to `--force=true`, so commit or stash before rerunning to avoid losing work. + +## Field Discovery & Supported Types + +Koi maps ActiveRecord attributes and associations to GOV.UK form helpers and table cells via `AttributeTypes` (`lib/generators/koi/helpers/attribute_types.rb:30`). + +| Schema hint | Form helper | Index cell | Show cell | Filters generated | +| --- | --- | --- | --- | --- | +| `string`, `text` | `form.govuk_text_field` | `row.text` | `row.text` | `attribute :name, :string` (`lib/generators/koi/helpers/attribute_types.rb:30`) | +| `integer` | `form.govuk_number_field` | `row.number` | `row.number` | `attribute :count, :integer` (`lib/generators/koi/helpers/attribute_types.rb:48`) | +| `boolean` | `form.govuk_check_box_field` | `row.boolean` | `row.boolean` | `attribute :flag, :boolean` (`lib/generators/koi/helpers/attribute_types.rb:66`) | +| `date` | `form.govuk_date_field` | `row.date` | `row.date` | `attribute :published_on, :date` (`lib/generators/koi/helpers/attribute_types.rb:84`) | +| `datetime` | _no form control generated_ | `row.datetime` | `row.datetime` | `attribute :starts_at, :date` (`lib/generators/koi/helpers/attribute_types.rb:102`) | +| `rich_text` | `form.govuk_rich_text_area` | — | `row.rich_text` (`lib/generators/koi/helpers/attribute_types.rb:116`) | +| Active Storage attachment | `form.govuk_image_field` | — | `row.attachment` (`lib/generators/koi/helpers/attribute_types.rb:126`) | +| `enum` (defined via `enum` API) | `form.govuk_enum_select` | `row.enum` | `row.enum` | `attribute :status, :enum` (`lib/generators/koi/helpers/attribute_types.rb:142`) | +| `belongs_to` | _no form control generated_ | — | `row.link` to the associated record (`lib/generators/koi/helpers/attribute_types.rb:136`) | +| `ordinal` column | Triggers orderable mode; no direct form field | Adds `row.ordinal` drag handle | — | — | +| `archived_at` column + `Koi::Model::Archivable` | Adds archive actions, archived view, bulk selection | — | `row.boolean :archived` (`lib/generators/koi/helpers/attribute_types.rb:163`) | — | + +**Gotchas:** +- `datetime` and `belongs_to` columns don’t receive automatic form inputs. Add your own controls (e.g., `form.govuk_date_field(:starts_at)` or a `form.govuk_collection_select`) after generation. +- Attachment form controls default to `govuk_image_field`. Switch to `form.govuk_document_field` if the upload isn’t an image and update hints using `Koi::Form::Builder` overrides (`lib/koi/form/builder.rb:35`). +- On Rails 8+ declare enums with the positional syntax (`enum :status, { draft: 0, published: 1 }`). The older keyword form (`enum status: { ... }`) raises `ArgumentError: wrong number of arguments` when the generators constantize your model. +- Generated factories still assign integer enum values, so request specs post `'1'` and Rails 8 rejects it. Change the factory to use the symbolic key (e.g., `status { :draft }`) until the template is updated. +- Restart the Rails server after generating a module so `config/initializers/koi.rb` is reloaded—otherwise the new entry won’t show up in the admin modules menu. + +## Pick a display attribute + +Koi renders record instances in many places (`show` headers, breadcrumbs, select options) by calling `to_s`. Without an override you’ll see the Ruby inspector string (`#`), so set a sensible default in the model as soon as you scaffold the module: + +```ruby +class Article < ApplicationRecord + def to_s = title.presence || "Article ##{id}" +end +``` + +Picking a stable attribute (title, name, slug) keeps UI labels consistent across the admin shell, exports, and any custom components that rely on `to_s`. + +**Alternatives:** +- If you prefer to vary what’s shown on the `show` page, replace the header markup directly: `

<%= article.title.presence || "Article ##{article.id}" %>

`. +- For more complex display logic (e.g., combining attributes), implement a decorator or view helper, but still provide a fallback `to_s` so other surfaces remain readable. + +## Generated Forms & Custom Inputs + +- Form templates iterate through the discovered attributes in order and render `govuk_*` helpers (`lib/generators/koi/admin_views/templates/_form.html.erb.tt:3`). Reorder or group fields by rearranging the generated ERB. +- If you pass attribute arguments to `koi:admin`, the generated forms respect the order you provide. When you omit arguments, discovery follows the migration order for columns—i.e., the order you specified when running `koi:model`—then appends attachments, rich text, and `belongs_to` associations in declaration order. +- `Koi::FormBuilder` injects admin-friendly buttons (`form.admin_save`, `form.admin_delete`) and smart defaults for file hints and ActionText direct uploads (`lib/koi/form/builder.rb:13`). +- The first attribute becomes the default index link via `row.link`, so choose something human-readable (title/name) or update the generated `index.html.erb`. +- For structured content components, mix in helpers from `Koi::Form::Content` to reuse heading/style selectors inside custom forms (`lib/koi/form/content.rb:8`). + +### Date Inputs + +`form.govuk_date_field` renders the GOV.UK composite day/month/year inputs, giving you accessible validation without relying on browser-specific date pickers. If you prefer a single text input or JS calendar, replace the helper after generation. For datetimes, add your own pair of date + time inputs—Koi does not guess the UX for you. + +### Files & Attachments + +- Controllers include `Koi::Controller::HasAttachments#save_attachments!` so failed validations don’t drop uploads (`app/controllers/concerns/koi/controller/has_attachments.rb:23`). Call it before re-rendering when you customise `create` / `update` flows. +- Multiple attachments are supported: the generator already whitelists arrays for attachment params when the attribute responds accordingly (`lib/generators/koi/admin_controller/templates/controller.rb.tt:110`). +- For non-image uploads swap `govuk_image_field` with `govuk_document_field` to get document-specific hints (size limits come from config in `lib/koi/form/builder.rb:35`). + +## List Views, Ordering, and Pagination + +### Table Layout + +- Lists render inside `table_with`, and the first attribute becomes the linked column. Subsequent attributes follow in declaration order (`lib/generators/koi/admin_views/templates/index.html.erb.tt:33`). +- When the module is archivable, a selection column and bulk archive button appear automatically (`lib/generators/koi/admin_views/templates/index.html.erb.tt:19`). +- Summary pages render every “show attribute” using `row.text`, `row.enum`, etc. (`lib/generators/koi/admin_views/templates/show.html.erb.tt:10`). + +### Collections & Filters + +Each controller defines an inner `Collection` class extending `Admin::Collection` (search + Pagy) with type-aware filters for every attribute that supports it (`lib/generators/koi/admin_controller/templates/controller.rb.tt:124`). Filtering and search are powered by `Katalyst::Tables::QueryComponent`, automatically inserted when `query?` returns true (`lib/generators/koi/admin_views/templates/index.html.erb.tt:15`). + +### Sorting & Pagination + +- `config.sorting` defaults to the first string column unless the resource is orderable; you can override it manually inside the generated collection (`lib/generators/koi/helpers/attribute_helpers.rb:72`). +- Non-orderable modules paginate via Pagy with the `Koi::PagyNavComponent`, adding keyboard navigation (`app/components/koi/pagy_nav_component.rb:3`, `app/helpers/koi/pagy.rb:9`). + +### Making a Module Orderable + +- Add an `ordinal:integer` column to the table and rerun `koi:model` + `koi:admin`. The presence of an `ordinal` attribute enables the orderable branch (`lib/generators/koi/helpers/resource_helpers.rb:64`). +- The controller gains an `order` action that expects a hash of IDs/ordinals and consults `order_params` (`lib/generators/koi/admin_controller/templates/controller.rb.tt:118`). +- The index view renders `row.ordinal` and a `table_orderable_with` footer so users can drag-and-drop rows (`lib/generators/koi/admin_views/templates/index.html.erb.tt:27`). Pagination is disabled while orderable mode is active (`lib/generators/koi/helpers/resource_helpers.rb:68`). +- `koi:model` inserts a default scope that orders by `ordinal` so existing queries line up with the drag-and-drop UI (`lib/generators/koi/model/model_generator.rb:13`). + +## Archiving & Bulk Actions + +Including `Koi::Model::Archivable` in the model adds the `archived_at` scope and helpers (`app/models/concerns/koi/model/archivable.rb:17`). When the generator spots an `archived_at` column: + +- Extra routes (`archive`, `restore`, `archived`) are added (`lib/generators/koi/admin_route/admin_route_generator.rb:33`). +- The index view adds a bulk archive action and a link to the archived list (`lib/generators/koi/admin_views/templates/index.html.erb.tt:21`). +- Destroy actions archive first, then delete once already archived (`lib/generators/koi/admin_controller/templates/controller.rb.tt:64`). + +## Navigation Registration + +Every admin module is added to `Koi::Menu.modules` with a label derived from the namespace and model name. The generator rewrites the initializer block, keeping entries alphabetical (`lib/generators/koi/admin_route/admin_route_generator.rb:46`). Move the item into groups or submenus by editing `config/initializers/koi.rb` after generation. Because this lives in an initializer, restart the app (or reload Spring) before expecting the navigation dialog to pick up changes. + +## Pagination UX + +`Koi::Controller` sets default component bindings for tables, queries, and pagination (`app/controllers/concerns/koi/controller.rb:13`). Pagy navigation gains a Stimulus controller so admins can page with ←/→ shortcuts (`app/helpers/koi/pagy.rb:9`). The whole table is wrapped with `Koi::TableComponent`, which ensures consistent styling and makes helper cells such as `row.link` and `row.attachment` available (`app/components/koi/table_component.rb:4`). + +## Common Customisations + +- **Add custom filters** by extending the inner `Collection` and declaring attributes manually; anything you add shows up in `table_query_with` automatically. +- **Override form layouts** using ViewComponents or partials if you need multi-column layouts—just ensure the submit buttons continue to call `form.admin_save` so styles remain consistent. +- **Additional actions** go inside the controller and can be surfaced in the header via `actions_list`. +- **Non-standard inputs** (e.g., slug sync, toggles) can hook into existing Stimulus controllers such as `sluggable` or `show-hide`. + +## Generator Reference + +### `koi:model` + +- Shares options with `rails g model` (`--timestamps=false`, `--parent=`, etc.). +- Adds the `admin_search` scope automatically, falling back to SQL `LIKE` when no string columns exist. +- Inserts a default scope when an `ordinal` column is present to support orderable lists. + +### `koi:admin` + +- Usage: `bin/rails g koi:admin NAME [field:type ...]`. +- Options: `--force=false`, `--skip-admin_controller`, `--skip-admin_views`, `--skip-admin_route` when regenerating selectively. +- Delegates to the sub-generators with the same attribute list; rerun after schema changes to refresh strong params and templates. + +### Sub-generators + +- `koi:admin_controller`, `koi:admin_views`, and `koi:admin_route` are available individually for targeted refreshes. They honour the same attribute parsing and `--force` flag. + +## Gotchas & Reminders + +- Generators overwrite files without prompting. Keep commits small so you can regenerate confidently. +- `belongs_to` relationships only appear on the show page; add your own form fields/filters if you need to edit them in the UI. +- When handling attachments, always call `save_attachments!` before rendering to avoid losing uploads. +- Pagination is disabled for orderable lists. If you have a large dataset, consider a dedicated reorder screen instead of relying on the default drag-and-drop. +- Navigation entries are stored in code, not the database. Remember to adjust `config/initializers/koi.rb` when you rename modules. +- After scaffolding, step through the CRUD screens (create → show → edit → delete) to confirm helpers, validations, and menu wiring behave as expected. + +With these patterns in hand you can scaffold, regenerate, and customise Koi admin modules quickly while staying inside the conventions baked into the engine. diff --git a/docs/archive/koi-basic-module-guide.md b/docs/archive/koi-basic-module-guide.md new file mode 100644 index 00000000..4437f0f8 --- /dev/null +++ b/docs/archive/koi-basic-module-guide.md @@ -0,0 +1,131 @@ +# Building a Basic Koi Module + +Use this guide when you want the lightest-possible CRUD surface in Koi: no ordering, no archiving, no associations—just a Rails model backed by standard columns and the admin scaffolding that Koi generates for you. It is written for both humans and coding LLMs so the process can be repeated reliably. + +## What a Koi Module Provides + +After generation you get: + +- An ActiveRecord model wired with an `admin_search` scope and optional defaults from `koi:model` (`lib/generators/koi/model/model_generator.rb`). +- An admin controller, requests spec, GOV.UK-flavoured views, and fully wired routes from `koi:admin` (fan-out to controller, views, and route generators under `lib/generators/koi`). +- Automatic menu registration in `config/initializers/koi.rb` so the module appears in the admin UI once the server restarts. +- Form, table, and show layouts that follow Koi’s conventions, including breadcrumb/header wiring and Turbo-ready responses. + +## Before You Start + +1. Run `bin/setup` once per repository clone to install dependencies and prepare `spec/dummy` if you have not already. +2. Ensure `config/routes/admin.rb` and `config/initializers/koi.rb` exist. If not, run `bin/rails g koi:admin_route` and restart the server. +3. Decide on a model name and the columns you need. For the “basic module” pattern, stick to primitive columns (string, text, integer, boolean, date, datetime). Skip `archived_at`, `ordinal`, attachments, and associations so the generated UI stays minimal. + +## Step-by-step Workflow + +### 1. Generate the model and migration + +Use the `koi:model` generator so the model receives the `admin_search` scope. + +```sh +bin/rails g koi:model Article title:string summary:text published_on:date featured:boolean +``` + +- The generator accepts the same options as `rails g model` (for example `--skip-fixtures`, `--timestamps=false`). +- `koi:model` inserts either a `pg_search_scope` or a SQL fallback depending on whether `PgSearch::Model` is loaded (`lib/generators/koi/model/model_generator.rb`). + +### 2. Run the migration + +```sh +bin/rails db:migrate +``` + +Running the migration is essential before invoking `koi:admin`. If you skip this step, `koi:admin` cannot introspect the schema and you will have to pass every column to the generator manually. + +### 3. Generate the admin surface + +```sh +bin/rails g koi:admin Article +``` + +Key behaviours to understand: + +- With no attribute arguments, `koi:admin` inspects the model you just migrated and collects columns, rich text associations, enums, and attachments (`lib/generators/koi/helpers/attribute_helpers.rb`). This keeps the form/view templates in sync with the database. +- To control field order explicitly, pass attributes in the desired sequence: `bin/rails g koi:admin Article title:string summary:text published_on:date featured:boolean`. The generator uses the attribute array as-is when rendering `_form.html.erb` (`lib/generators/koi/admin_views/templates/_form.html.erb.tt`). +- The admin generator fans out to: + - `koi:admin_controller` → `app/controllers/admin/articles_controller.rb` and `spec/requests/admin/articles_controller_spec.rb`. + - `koi:admin_views` → form, index, new/edit, and show templates under `app/views/admin/articles`. + - `koi:admin_route` → adds `resources :articles` to `config/routes/admin.rb` and updates the menu initializer (`lib/generators/koi/admin_route/admin_route_generator.rb`). +- All three generators run with `--force=true` by default, so files are overwritten without a prompt. Commit or stash before regenerating. + +### 4. Restart the server + +The navigation entry lives in `config/initializers/koi.rb`, so restart Spring/your Rails server to see the new module in the admin menu. Without the restart, the initializer is not reloaded and the menu will not change. + +### 5. Verify CRUD behaviour + +1. Sign in at `/admin`. +2. Confirm the module appears in the menu (alphabetically unless you reorder the initializer). +3. Create a record and save it—forms use GOV.UK inputs wired in `_form.html.erb`. +4. Visit the show page. The `

` renders `record.to_s` (`lib/generators/koi/admin_views/templates/show.html.erb.tt:6`), so define `def to_s = title` (or another identifying attribute) in your model to display the correct heading. +5. Edit and delete to confirm the controller wiring and flash notices work as expected. + +## Generator Reference for Basic Modules + +### `koi:model` + +- Inherits every option from `rails g model` (timestamps, parent class, fixtures, etc.). +- Adds `admin_search` automatically. If your schema lacks any string columns, the fallback scope becomes a simple `where` on whatever columns exist. +- Adds a default scope when an `ordinal` column is present; avoid that column for the “basic module” path. + +### `koi:admin` + +- Signature: `bin/rails g koi:admin NAME [field:type ...]`. +- Options: `--force=false` (preview without overwriting), `--skip-admin_controller`, `--skip-admin_views`, `--skip-admin_route` (skip individual hooks if you need to customise manually). +- Generates helpers for index/new/edit/show routes based on the namespace logic in `lib/generators/koi/helpers/resource_helpers.rb`. + +### `koi:admin_controller`, `koi:admin_views`, `koi:admin_route` + +You rarely run these directly, but they are available for targeted regeneration (for example, `bin/rails g koi:admin_views Article` if you only changed columns). They share the same attribute parsing and `--force` option. + +## Column Types and UI Output + +The attribute-introspection layer maps common Rails column types to Koi’s form and table helpers (`lib/generators/koi/helpers/attribute_types.rb`). For basic modules, expect the following defaults: + +| Column type | Form helper | Index cell | Show cell | +| --- | --- | --- | --- | +| `string`, `text` | `form.govuk_text_field` | `row.text` | `row.text` | +| `integer` | `form.govuk_number_field` | `row.number` | `row.number` | +| `boolean` | `form.govuk_check_box_field` | `row.boolean` | `row.boolean` | +| `date` | `form.govuk_date_field` | `row.date` | `row.date` | +| `datetime` | _no input generated_; add one manually if you need it | `row.datetime` | `row.datetime` | +| `enum` | `form.govuk_enum_select` | `row.enum` | `row.enum` | + +Types not listed fall back to the string helpers. If you introduce attachments, rich text, or associations later, rerun the generator and update the form manually as needed. + +## Field Ordering Rules + +- `koi:admin` renders fields in the order of the `attributes` array it receives. When introspecting a migrated model, that order matches how columns appear in the database schema. Passing explicit attributes lets you override it. +- The first column becomes the clickable link on the index table (`lib/generators/koi/admin_views/templates/index.html.erb.tt:22`). Choose an identifying field (for example, `title`) if you want a particular column to link to the show page. +- To reorder after generation, edit `_form.html.erb`, `index.html.erb`, and `show.html.erb` manually or rerun the generator with the desired order. + +## Regeneration Strategy + +- When the schema changes (new column, renamed field), rerun `koi:admin` for that model. The generator rewrites controllers, views, routes, and menu entries to match the new schema. +- Because `--force` defaults to true, regeneration overwrites local edits. Either: + - keep custom logic in separate partials/components that you reapply after regeneration, or + - use `--force=false` to generate side-by-side files and copy changes across. +- You can regenerate just the controller, views, or routes if that is all that changed. + +## Putting It All Together (Worked Example) + +1. **Generate model:** `bin/rails g koi:model Announcement title:string body:text published_on:date featured:boolean` +2. **Migrate:** `bin/rails db:migrate` +3. **Generate admin:** `bin/rails g koi:admin Announcement` +4. **Set the display name:** + ```ruby + # app/models/announcement.rb + class Announcement < ApplicationRecord + def to_s = title + end + ``` +5. **Restart:** `bin/dev` (or restart your existing server process). +6. **Verify in the UI:** create, edit, show, and delete announcements from `/admin/announcements`. + +Following these steps yields a fully functional, minimal CRUD module that plays nicely with the rest of Koi. When you are ready for richer behaviour (archiving, ordering, attachments), jump to `docs/admin-module-workflow.md` for the advanced patterns. From 8915286917383f9a43fd61ef230066e85e4c8260 Mon Sep 17 00:00:00 2001 From: Jason Sidoryn Date: Fri, 19 Sep 2025 13:07:37 +0930 Subject: [PATCH 2/4] docs: add setup, user, and contributor guides --- AGENTS.md | 19 +++ docs/koi-setup-guide.md | 254 ++++++++++++++++++++++++++++++++++++ docs/koi-user-guide.md | 278 ++++++++++++++++++++++++++++++++++++++++ docs/user-management.md | 146 +++++++++++++++++++++ 4 files changed, 697 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/koi-setup-guide.md create mode 100644 docs/koi-user-guide.md create mode 100644 docs/user-management.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0f0fe701 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Engine code lives under `app/`, mirroring a standard Rails engine: controllers, components, and views compose admin UI, while `app/assets` and `app/javascript/koi` hold the source for bundles shipped via Rollup. Shared Ruby modules and tasks sit in `lib/`, including generators and Rake extensions. Specs reside in `spec/`, with the `spec/dummy` Rails app providing an integration harness; treat it as disposable scaffolding, not a source of production logic. + +## Build, Test, and Development Commands +Run `bin/setup` once to install gem and JavaScript dependencies and to scaffold the dummy app. Use `bundle exec rake build` to compile assets (`yarn build`) and prepare the engine. `bundle exec rspec` exercises the full suite; pair it with `bundle exec rake lint` to run RuboCop, ERB lint, and Prettier checks. For interactive demos, launch the sandboxed host app with `bin/dev`, which proxies to `spec/dummy/bin/dev`. + +## Coding Style & Naming Conventions +Ruby follows the `rubocop-katalyst` profile: two-space indentation, snake_case for methods and variables, and CamelCase for classes/modules. Prefer service objects and view components that live alongside peers in `app/components`. JavaScript and stylesheet changes should pass Prettier's default formatting; keep ES modules colocated with their Rails counterpart under `app/javascript/koi`. When extending generators or Rake tasks, namespaced constants should sit under `Katalyst::Koi` to avoid collisions. + +## Testing Guidelines +Author specs with RSpec; name files `*_spec.rb` within the matching directory (e.g., `spec/components`). Exercise factories via `spec/factories` and update fixtures only when assertions demand it. System specs should target the dummy app flows; reset state with provided helpers in `spec/support`. Before raising a PR, ensure `bundle exec rspec` passes locally and add coverage for each new behaviour or regression fix. + +## Commit & Pull Request Guidelines +Commits in this repo favour short, descriptive subjects in sentence case (e.g., `Fix input size for select elements` or `Churn: update dependencies`). Group unrelated changes into separate commits to keep rollbacks trivial. Pull requests should summarise intent, reference any Katalyst issue IDs, and include screenshots or GIFs for admin UI tweaks. Mention required follow-up migrations or configuration changes explicitly so downstream applications can plan upgrades. + +## Security & Maintenance +Run `bundle exec rake security` before releases to scan the engine with Brakeman. Keep Rollup outputs clean by running `yarn clean` when removing assets, and verify the dummy app still boots after dependency updates to catch breaking Rails changes early. diff --git a/docs/koi-setup-guide.md b/docs/koi-setup-guide.md new file mode 100644 index 00000000..cc1a73d6 --- /dev/null +++ b/docs/koi-setup-guide.md @@ -0,0 +1,254 @@ +# Koi Setup Guide + +Use this guide to get a Koi-backed Rails application running locally with an admin account and a passing test suite. It favours the `koi-template` generator (recommended for new projects) and includes a manual path for existing apps. Follow every "Verify" checkpoint to ensure the environment is ready for development. + +## Prerequisites + +- macOS or Linux with Homebrew/apt tooling. +- Ruby 3.2+ (managed via `rbenv` or `ruby-install`), Bundler, and Rails 7.1 or newer (`gem install rails`). +- SQLite 3 (ships with macOS; `brew install sqlite` or `sudo apt install sqlite3` on Linux). +- Optional: Foreman (`gem install foreman`) for the `bin/dev` workflow. +- Optional (engine contributors only): Node.js 18+ and Yarn for rebuilding Koi’s bundled admin JavaScript. + +**Verify** + +```sh +ruby -v # expect 3.2.x +rails -v # expect 7.1.x or newer +sqlite3 -version +# Optional for engine contributors +# node -v && yarn -v +``` + +## Standard Path: Bootstrap with koi-template (Recommended) + +### 1. Generate a new Rails app that targets SQLite + +Run `rails new` with the template and the SQLite adapter (swap `` and the template path if you cloned `koi-template` elsewhere). + +```sh +rails new -d sqlite3 \ + --skip-action-cable --skip-action-mailbox --skip-action-mailer \ + --skip-ci --skip-dev-gems --skip-docker --skip-git \ + --skip-jbuilder --skip-kamal --skip-keeps --skip-solid \ + --skip-system-test --skip-test --skip-thruster \ + -a propshaft \ + -m /template.rb +``` + +If you prefer to run it from GitHub, swap the `-m` argument for `https://raw.githubusercontent.com/katalyst/koi-template/main/template.rb`. + +If the generator stops part-way (for example, because Bundler could not reach the network), delete the partially created directory (`rm -rf `) before retrying. This avoids the follow-up run prompting to overwrite files mid-generation. + +The template installs dependencies, copies initialisers, runs migrations (including Koi), and commits the result. + +**Verify** + +```sh +cd +bin/rails about +``` + +You should see the application banner without errors. + +### 2. Install gems and prepare the database + +The generated project ships with a `bin/setup` script that installs Bundler, resolves gems, prepares the SQLite database, clears logs, and restarts the app server. + +```sh +bin/setup +``` + +**Verify** + +```sh +bin/rails db:version # prints the latest schema version +sqlite3 db/development.sqlite3 '.tables' # shows koi_* tables among others +``` + +Seeing tables such as `admin_users`, `koi_menu_items`, and `url_rewrites` confirms migrations ran. + +### 3. Seed Koi defaults and create an admin user + +The template already appends `Koi::Engine.load_seed` to `db/seeds.rb`. Run the seeds; in development this creates the default Katalyst admin (name from `id -F`, email `@katalyst.com.au`, password `password`). You can use that seed account immediately or create additional admins as needed. See `user-management.md` for detailed guidance on how the seeded account works and how to adjust it safely. + +```sh +bin/rails db:seed +``` + +Create a working admin account with the helper script: + +```sh +bin/admin-adduser +``` + +- **Katalyst employees**: when you run the script in an environment where `Rails.env.local?` returns true (the common local setup in Katalyst projects) and your Unix account exposes a full name, the script uses `@katalyst.com.au` automatically. +- **Explicit creation**: supply flags if the defaults are missing or you are onboarding a client. + +```sh +bin/admin-adduser -n "Casey Client" -e "casey@example.com" +``` + +If the seed already created an account for your shell user, `bin/admin-adduser` exits early with a warning. In that case either pass `-e` to create a different user, or run `bin/admin-reset ` to generate a fresh login link for the existing account. `user-management.md` covers additional workflows, including changing the seeded email and avoiding redirect loops during logout. + +The script prints either a full login URL or a relative `/admin/session/token/` path; copy it into your browser to finish activation. + +**Verify** + +- `Admin::User.count` should be ≥ 1: `bin/rails runner 'puts Admin::User.count'`. +- A login URL or path appears in the script output. + +### 4. Start the application and confirm the UI + +Use `bin/dev` (Foreman) to launch Rails, CSS, and JS watchers. Fallback: `bin/rails server` if you do not have Foreman installed. + +```sh +bin/dev +``` + +Visit these URLs in your browser: + +- `http://localhost:3000/` – shows the scaffolded homepage from the template. +- `http://localhost:3000/admin` – loads the Koi admin shell. + +Log in using the credentials or token from the previous step. In development, `Koi.config.authenticate_local_admins` signs you in automatically when an `Admin::User` exists with your shell email. Disable this behaviour by adding `Koi.configure { |c| c.authenticate_local_admins = false }` in `config/initializers/koi.rb` if you need to exercise the full login flow. + +**Verify** + +- The admin header, navigation dialog, and default dashboard widgets render without exceptions. +- You can visit `/admin/admin_users` and see the account you created. + +### 5. Run the test suite + +Koi projects generated by the template use RSpec with system specs that assert the admin login flow works. + +```sh +bundle exec rspec +``` + +**Verify** + +- Command exits with status `0`. +- The summary reports `0 failures` (a fresh template run typically shows ~10–20 examples). + +At this point you have a working development environment, an accessible admin UI, and green tests—ready for feature work. + +## Alternative Path: Add Koi to an Existing Rails App + +Follow this track when you cannot regenerate the app from the template (e.g. retrofitting Koi into a legacy project). + +### 1. Add dependencies and generators + +1. Add the gem to your `Gemfile`: + ```ruby + gem "katalyst-koi" + ``` +2. Run `bundle install`. +3. Ensure Rails generators align with Koi expectations (no assets/helpers, RSpec enabled) and that Koi loads immediately after the main app. Add the following to `config/application.rb` inside the application class if it is not present: + ```ruby + config.generators do |g| + g.assets false + g.helper false + g.stylesheets false + g.test_framework :rspec + end + + config.railties_order = [:main_app, Koi::Engine, :all] + ``` + +**Verify** + +```sh +bundle exec rails about +``` + +The command should report versions without raising load errors. + +### 2. Install Koi assets, routes, and migrations + +Run the engine installers and generators from your app root: + +```sh +bin/rails katalyst_koi:install:migrations +bin/rails db:migrate +bin/rails g koi:admin_route +``` + +- Add `draw(:admin)` to `config/routes.rb` if the generator did not append it: + ```ruby + Rails.application.routes.draw do + draw(:admin) + # ...your existing routes... + end + ``` +- Review `config/initializers/koi.rb` and keep the default menu buckets unless you have a bespoke navigation setup. + +**Verify** + +```sh +bin/rails routes | grep '^admin' +``` + +You should see entries such as `admin_root` and `admin_session`. + +### 3. Configure seeds and create an admin + +Append the engine seeds so core data (default admin, cache tools, well-known entries) loads in development: + +```ruby +# db/seeds.rb +Koi::Engine.load_seed +``` + +Then run the seeds and create an admin user suitable for your environment. + +```sh +bin/rails db:seed +bin/rails runner "Admin::User.create!(name: 'Admin', email: 'admin@example.com', password: 'changeme') unless Admin::User.exists?(email: 'admin@example.com')" +``` + +For employee-specific accounts, reuse the helper from the template by copying `bin/admin-adduser` into your project or building an equivalent Rake task. + +**Verify** + +```sh +bin/rails runner 'puts Admin::User.pluck(:email)' +``` + +Confirm the expected email(s) appear. + +### 4. Boot the app and validate the admin shell + +Start the server (`bin/dev` or `bin/rails server`), visit `/admin`, and complete a login with the credentials you created. + +**Verify** + +- `/admin` redirects unauthenticated users to `/admin/session/new`. +- After logging in, `/admin` renders the dashboard without errors. + +### 5. Run your test suite + +Execute the project's standard tests (RSpec or Minitest) to ensure Koi integrations did not break existing behaviour. + +```sh +bundle exec rspec # or bin/rails test if you use Minitest +``` + +Confirm the suite passes; investigate any failures related to fixtures or authentication before continuing. + +## Verification Checklist + +- `bin/rails about` succeeds. +- `bin/rails db:version` reports the latest migration and SQLite tables include `admin_users`. +- You can generate or reset an admin using `bin/admin-adduser` / `bin/admin-reset` or your custom script. +- Visiting `/admin` in the browser presents the Koi UI and allows navigation between default sections. +- `bundle exec rspec` (or your equivalent test command) finishes with `0 failures`. + +## Troubleshooting + +- **`bin/admin-adduser` exits early:** Pass `-n` and `-e` explicitly; some shells (especially Linux) do not support `id -F` for deriving your full name. +- **Login token prints as a relative path:** Prefix it with `http://localhost:3000` in development. Configure `Rails.application.routes.default_url_options[:host] = "localhost:3000"` for a persistent fix. +- **Auto-login hides the sign-in form:** Temporarily disable `Koi.config.authenticate_local_admins` in the initializer to test credentials manually. +- **Missing migrations or tables:** Re-run `bin/rails db:prepare` followed by `bin/rails db:migrate`. If you added Koi to an existing app, ensure fixtures/factories load `Admin::User` records as needed for specs. + +With these steps finished you have a reproducible workflow for standing up Koi locally—either via the purpose-built template or by hand—complete with an admin account, a browsable UI, and passing tests. diff --git a/docs/koi-user-guide.md b/docs/koi-user-guide.md new file mode 100644 index 00000000..af70a6d4 --- /dev/null +++ b/docs/koi-user-guide.md @@ -0,0 +1,278 @@ +# Koi Admin User Guide + +This guide explains how to use Koi to build consistent administration areas in client projects. It covers setup, configuration, the standard features that ship with the engine, and the patterns you'll follow when building custom modules. Share this document with new team members who need to work in Koi-backed apps. + +## Documentation Map + +- **Setting up a project:** see [`koi-setup-guide.md`](./koi-setup-guide.md) for end-to-end bootstrap steps (template workflow and retrofit path). +- **Managing admin users:** see [`user-management.md`](./user-management.md) for provisioning, authentication options, and day-to-day maintenance tasks. +- **Building admin modules:** see [`admin-module-workflow.md`](./admin-module-workflow.md) for the full generator-driven process and advanced tips on ordering, archiving, and regeneration. + +## What Koi Provides (and What It Does Not) + +Koi is a Rails engine that installs a fully-featured admin shell. It gives you: + +- Opinionated layouts, navigation, and styling, wired for Turbo, Stimulus, and GOV.UK form components. +- Authentication flows (password, passkeys/WebAuthn, optional OTP-based MFA, and passwordless login links) using the built-in `Admin::User` model. +- Shared administrative tooling such as menu management, cache clearing, URL rewrites, and `.well-known` document publishing. +- Helpers, Stimulus controllers, and view components that streamline CRUD screens built on `Katalyst::Tables` and `Katalyst::Content`. +- Generators to scaffold models, controllers, views, and navigation entries that match the engine’s conventions. + +Koi does **not** attempt to model business logic for client applications. You still add your own ActiveRecord models, policies/authorization, background jobs, and complex workflows. Koi’s job is to make the admin UI predictable and accessible across projects. + +## Getting Started + +Follow the dedicated setup playbook when bootstrapping or retrofitting an app—see [`koi-setup-guide.md`](./koi-setup-guide.md) for prerequisites, template usage, manual install steps, and verification checkpoints. + +At a glance, a fresh project typically involves: + +1. Creating/bootstrapping a Rails app (the `koi-template` handles most defaults). +2. Installing Koi with `bin/rails katalyst_koi:install:migrations` and running migrations. +3. Generating `config/routes/admin.rb` and `config/initializers/koi.rb` via `bin/rails g koi:admin_route`. +4. Seeding an initial `Admin::User` and ensuring Koi assets are referenced. +5. Starting the server and visiting `/admin` to confirm the shell loads. + +Use the setup guide for explicit commands, alternate workflows, and troubleshooting tips. + +### Development Tips + +- In development the default config `Koi.config.authenticate_local_admins` automatically signs in a matching admin user based on your shell `USER` (`app/controllers/admin/sessions_controller.rb:18`). Disable this for demos by setting `Koi.configure { |c| c.authenticate_local_admins = false }`. See `user-management.md` for deeper guidance on managing seeded admins and adjusting their emails. +- Run `bundle exec rspec` and `bundle exec rake lint` before committing changes, matching repository guidelines. +- Use `yarn build` (and `yarn clean` if you need to clear previous artifacts) to regenerate `app/assets/builds/katalyst/koi.min.js` if you change the JavaScript package. + +## Configuration Reference + +Configure Koi in an initializer (created by `koi:admin_route`) or any boot file. The defaults live in `lib/koi/config.rb`. + +| Setting | Default | Purpose | +| --- | --- | --- | +| `admin_name` | `"Koi"` | Display name in page titles and the header (`app/views/layouts/koi/application.html.erb:7`). | +| `authenticate_local_admins` | `Rails.env.development?` | Auto-login behaviour used in `Admin::SessionsController#authenticate_local_admin` (`app/controllers/admin/sessions_controller.rb:59`). | +| `resource_name_candidates` | `%i[title name]` | Candidate attributes used by view helpers to label resources. | +| `admin_stylesheet` | `"admin"` | Stylesheet tag rendered in layouts (`app/views/layouts/koi/application.html.erb:21`). | +| `admin_javascript_entry_point` | `"@katalyst/koi"` | JavaScript import map entry used by layouts (`app/views/layouts/koi/application.html.erb:24`). | +| `document_mime_types` / `image_mime_types` | Lists of allowed MIME types | Used by `Koi::Form::Builder` when rendering file fields (`lib/koi/form/builder.rb:28`). | +| `document_size_limit` / `image_size_limit` | `10.megabytes` | Size hints shown next to upload fields. | + +Call `Koi.configure` in an initializer to override values: + +```ruby +Koi.configure do |config| + config.admin_name = "Acme CMS" + config.admin_stylesheet = "admin_bundle" + config.admin_javascript_entry_point = "application" +end +``` + +### Menu Configuration + +`Koi::Menu` drives the navigation modal (`lib/koi/menu.rb`). The initializer created by `koi:admin_route` seeds three menu buckets: + +- `Koi::Menu.priority` – always shown first. Defaults include “View website” and “Dashboard”. +- `Koi::Menu.modules` – the main listing for your admin modules. Generators update this automatically. +- `Koi::Menu.advanced` – extra utilities such as Admin Users, URL Rewrites, `.well-known` documents, Sidekiq, Flipper, and the “Clear cache” button. +- `Koi::Menu.quick_links` – used on the dashboard to highlight shortcuts. + +Each hash key becomes a heading and each value is either a path or a nested hash for grouped links. Update the initializer to curate links: + +```ruby +Koi::Menu.modules = Koi::Menu.modules.merge( + "Content" => { + "Pages" => "/admin/pages", + "News" => "/admin/news_items", + } +) + +Koi::Menu.quick_links = { + "Support tickets" => "/admin/support_tickets", + "Release notes" => "/admin/releases", +} +``` + + +## Authentication and Security + +Koi wraps multiple authentication flows (password, OTP, passkeys, magic links) around `Admin::User`. For provisioning steps, invitation flows, and operational scripts, see [`user-management.md`](./user-management.md). + +- `Admin::SessionsController` coordinates the multi-step login sequence and hands off to the correct credential flow. +- `Koi::Middleware::AdminAuthentication` guards all `/admin/**` routes, while `Koi::Controller::RecordsAuthentication` records sign-in metadata. +- OTP/WebAuthn enrolments live in the admin profile, and cache clearing is surfaced via `Admin::CachesController` using the same middleware. +- Koi leaves authorisation to the host app—layer Pundit, ActionPolicy, or CanCanCan over generated controllers. + +## Navigation and Layout + +### Layouts and Frames + +- `app/views/layouts/koi/application.html.erb` is the default admin layout. It sets up the page title, includes configured assets, renders flash messages, and wraps page content in `.wrapper` containers. +- Turbo frame responses use `app/views/layouts/koi/frame.html.erb`, ensuring Turbo includes CSRF meta tags and the admin assets when rendering partial screens. +- The login area uses `app/views/layouts/koi/login.html.erb` with a slimmed-down stylesheet for a focused sign-in experience. + +### Header and Actions + +- Use `content_for :header` in your views to inject headings, breadcrumbs, and action links as shown in `app/views/admin/admin_users/index.html.erb` and `show.html.erb`. +- `Koi::HeaderHelper#breadcrumb_list` and `#actions_list` render accessible navigation strips (`app/helpers/koi/header_helper.rb`). They accept arbitrary list content. +- `Koi::HeaderComponent` wraps the same functionality in a ViewComponent when you prefer a component-based API (`app/components/koi/header_component.rb`). + +### Navigation Modal + +- The navigation dialog at the top-right is rendered via `app/views/layouts/koi/_application_navigation.html.erb`. It composes menu items using `navigation_menu_with` from `Katalyst::Navigation` and lists the current admin along with log-out links. +- The `navigation` Stimulus controller powers quick filtering, keyboard shortcuts, and modal open/close interactions (`app/javascript/koi/controllers/navigation_controller.js`). Keyboard mappings are defined on the `` element (`app/views/layouts/koi/application.html.erb:27`). + +### Dashboard Quick Links + +`Admin::DashboardsController` renders `Koi::Menu.dashboard_menu` so you can surface shortcuts for common admin tasks (`app/controllers/admin/dashboards_controller.rb` and `app/views/admin/dashboards/show.html.erb`). Update `Koi::Menu.quick_links` to curate this list. + +## Building Admin Modules + +For the step-by-step workflow—including generator usage, field ordering rules, archiving, ordering, and regeneration—refer to [`admin-module-workflow.md`](./admin-module-workflow.md). The notes below summarise the moving parts you will encounter once the scaffolding is in place. + +### Controllers + +`koi:admin` generates controllers under `Admin::` that inherit from `Admin::ApplicationController` so they pick up `Koi::Controller` (pagination, helper inclusion, CSRF/turbo-aware layout handling). The generator wires: + +- An inner `Collection` class backed by `Katalyst::Tables::Collection` for sorting, filtering, and pagination. +- Strong parameters built with `params.expect`, covering scalar attributes, attachments, and rich text. +- Optional collection actions such as `archived`, `archive`, `restore`, and `order` depending on detected columns. +- Matching GOV.UK-flavoured views plus RESTful routes/menu entries so the module appears immediately in navigation. + +When you build custom actions, call `save_attachments!` before re-rendering to preserve uploads (`app/controllers/concerns/koi/controller/has_attachments.rb:14`). + +### Collections and Tables + +`Admin::Collection` (`app/models/admin/collection.rb`) base class sets up search filtering using either `pg_search` or SQL `LIKE`. In each controller you define an inner `Collection` class to describe filterable attributes: + +```ruby +class Collection < Admin::Collection + config.sorting = :name + config.paginate = true + + attribute :name, :string + attribute :status, :enum + attribute :published_on, :date +end +``` + +In views, use the provided helpers: + +```erb +<%= table_query_with(collection:) %> + +<%= table_with(collection:) do |row, record| %> + <% row.link :name %> + <% row.boolean :published? %> + <% row.date :published_on %> +<% end %> + +<%= table_pagination_with(collection:) %> +``` + +`Koi::Tables::Cells` adds extra helper cells: + +- `row.link` outputs a link to the record’s show (or a custom URL) (`app/components/concerns/koi/tables/cells.rb:18`). +- `row.attachment` displays ActiveStorage attachments as downloads/thumbnails (`app/components/concerns/koi/tables/cells.rb:44`). +- Use `table_selection_with` for bulk actions, as shown in `app/views/admin/admin_users/index.html.erb`. + +### Forms + +`Koi::FormBuilder` combines `GOVUKDesignSystemFormBuilder` with helper shortcuts (`lib/koi/form_builder.rb`). Key helpers include: + +- `form.admin_save` / `form.admin_delete` / `form.admin_archive` / `form.admin_discard` for consistent action buttons (`lib/koi/form/builder.rb:13`). +- Automatic admin routes in `form_with` via `Koi::FormHelper` (`app/helpers/koi/form_helper.rb`). +- File field helpers use size limits from configuration. +- `Koi::Form::Content` provides macros for content block editors (heading fields, target selectors, etc.) used with `Katalyst::Content`. + +Remember to call `govuk_formbuilder_init` once when you render password fields so the GOV.UK show/hide toggle initialises (`app/views/admin/sessions/password.html.erb:12`). + +### Model Concerns + +- `Koi::Model::Archivable` adds an `archived_at` flag, default scopes, and helper methods such as `archive!`/`restore!` (see `app/models/concerns/koi/model/archivable.rb`). Use it for content that can be soft-deleted from the interface. +- `Koi::Model::OTP` exposes `requires_otp?` and `otp` helpers for models that store an `otp_secret` (`app/models/concerns/koi/model/otp.rb`). It is already included in `Admin::User` but can be reused if you expose other MFA-enabled admin records. + +### Modals and Turbo Frames + +- `koi_modal_tag` renders a Turbo-driven modal frame (`app/helpers/koi/modal_helper.rb`). Wrap forms inside the modal and pair with `koi_modal_header` / `koi_modal_footer` for consistent markup. +- The `modal` Stimulus controller manages lifecycle events (open, close, dismiss) and automatically closes on successful submissions when the submit button has `data-close-dialog` (`app/javascript/koi/controllers/modal_controller.js`). +- The self-variant views (e.g., `show.html+self.erb`) demonstrate how to provide tailored layouts when the current user is editing their own record. Rails automatically renders the `:self` template when `request.variant << :self` is set in the controller (`app/controllers/admin/admin_users_controller.rb:115`). + +### Content and Navigation Editors + +Koi integrates the `Katalyst::Content` and `Katalyst::Navigation` engines: + +- `Katalyst::Content.config.base_controller` is set to `Admin::ApplicationController` so content editing screens run inside the Koi layout (`lib/koi/engine.rb:34`). +- Error summaries for both engines render through Koi components (`app/components/koi/content/editor/errors_component.rb` and `app/components/koi/navigation/editor/errors_component.rb`). +- Use `mount Katalyst::Content::Engine, at: "admin/content"` directly from Koi’s routes (`config/routes.rb:24`) and manage navigation structures under `/admin/navigation`. + +## Supporting Modules + +### Admin Users + +Koi ships a full admin-user management surface (index, profile, OTP/passkey enrolment, archive/restore). Treat it as the reference implementation for new CRUD modules. For provisioning flows, CLI helpers, and operational policies, see [`user-management.md`](./user-management.md). + +### URL Rewrites + +`UrlRewrite` records redirect stale paths to new URLs (`app/models/url_rewrite.rb`). The middleware `Koi::Middleware::UrlRedirect` intercepts 404 responses and applies active rewrites before the response is returned (`lib/koi/middleware/url_redirect.rb`). Use the admin UI under `/admin/url_rewrites` to manage entries. + +### .well-known Documents + +`WellKnown` allows administrators to publish arbitrary files under `/.well-known/:name` (`app/models/well_known.rb`). The public route is defined in `config/routes.rb:33`. Use this for ownership proofs and verification files. + +### Release Metadata + +`Koi::Release` reads `VERSION` and `REVISION` files and exposes them as `` tags in the head (`lib/koi/release.rb`). Override or populate these files in deployments so admins can see which build is running (`app/views/layouts/koi/_application_header.html.erb:8`). + +### Caching Controls + +`Koi::Caching` exposes two settings (`lib/koi/caching.rb`): `enabled` and `expires_in`. Toggle or override them if you integrate custom caching strategies. + +### Flipper Integration + +If `Flipper` is present, the initializer at `config/initializers/flipper.rb` registers an `:admins` group that considers any unarchived `Admin::User` a feature toggle actor. + +## JavaScript and Styling + +### Stimulus Controllers + +Koi auto-loads Stimulus controllers from `app/javascript/koi/controllers/index.js`. Highlights include: + +- `keyboard` – global keyboard shortcuts for search (`/`), create (`N`), navigation (`G`), cancel (Esc), and pagination arrows (`app/javascript/koi/controllers/keyboard_controller.js`). +- `navigation` / `navigation-toggle` – open, close, and filter the navigation dialog. +- `flash` – dismiss flash messages. +- `modal` – manage Turbo frame modals. +- `form-request-submit` – programmatically trigger `form.requestSubmit()`. +- `webauthn-authentication` / `webauthn-registration` – handle passkey login/registration flows by exchanging JSON with the WebAuthn browser APIs (`@github/webauthn-json`). +- `pagy-nav` – integrates keyboard pagination shortcuts with Pagy nav links. +- `clipboard` – copy invitation URLs to the clipboard. +- `show-hide` and `sluggable` – minor UX niceties for collapsible panels and slug fields. + +If you add controllers in your host app, either extend `app/javascript/koi/application.js` or mount them under your own namespace and load them alongside Koi. + +### Custom Elements and Utilities + +- `koi-toolbar` custom element wraps query toolbars with appropriate ARIA roles (`app/javascript/koi/elements/toolbar.js`). +- `Transition` utility provides a composable API for show/hide animations and is used by the `show-hide` controller (`app/javascript/koi/utils/transition.js`). + +### Asset Bundling + +- The Rollup config (`rollup.config.mjs`) builds minified bundles into `app/assets/builds/katalyst`. Run `yarn build` after editing JavaScript modules. +- Stylesheets live under `app/assets/stylesheets/koi`. `index.css` imports GOV.UK utilities, content/navigation/table stylesheets, and engine-specific layers. Extend these files (or override custom properties) in your host app’s own stylesheet bundle if you need branding tweaks. +- Icons are pure CSS masks defined in `app/assets/stylesheets/koi/icons.css`; add new masks next to `app/assets/images/koi/logo.svg` and follow the same pattern. + +## Working With Content + +- `Koi::Form::Content` convenience methods help build structured content blocks (headings, URLs, targets, etc.) when authoring `Katalyst::Content` components (`lib/koi/form/content.rb`). Include this module in your form object or `FormBuilder` to reuse the macros. +- The content editor Stimulus controllers are included via the `@katalyst/content` package automatically (loaded in `app/javascript/koi/controllers/index.js`). +- Direct uploads for Action Text fields are wired through the same Stimulus controllers with preconfigured data attributes in `Koi::Form::Builder#govuk_rich_text_area`. + +## Testing and Tooling + +- Specs live in `spec/` and rely on RSpec with a dummy Rails app. Copy patterns from the admin controller/request specs to structure your own tests (`lib/generators/koi/admin_controller/templates/controller_spec.rb.tt`). +- Use `FactoryBot` factories shipped with the engine (`lib/koi/engine.rb:42` ensures they load when FactoryBot is present). +- To regenerate the dummy app in this repository, run `bundle exec thor dummy:install` (see `lib/tasks/dummy.thor`). This is primarily for engine development but illustrates how Koi expects host apps to be structured. + +## Checklist For New Modules + +The authoritative checklist lives in [`admin-module-workflow.md`](./admin-module-workflow.md). Use that guide for the full sequence (generating models, scaffolding admin modules, wiring navigation, adding authorisation/tests, and smoke-testing the UI). Keep this user guide handy for background context while the workflow guide drives the implementation steps. + +--- + +Koi delivers a ready-made administrative experience that stays out of your domain logic. Once the engine is installed, lean on the generators, helpers, and Stimulus controllers described above to create consistent, maintainable admin interfaces for every client build. diff --git a/docs/user-management.md b/docs/user-management.md new file mode 100644 index 00000000..93918896 --- /dev/null +++ b/docs/user-management.md @@ -0,0 +1,146 @@ +# Koi User Management + +Use this guide to understand how the Koi engine models admin users, which authentication features ship out of the box, and how to provision, invite, and maintain users in a Koi-backed application. Treat it as the companion to the setup guide—reference it whenever you add new administrators or write code that depends on the current admin. + +## Overview: How Koi Handles Admin Users + +- **Model**: `Admin::User` (`app/models/admin/user.rb`) stores names, emails, optional passwords, and sign-in timestamps on the `admins` table. It mixes in `Koi::Model::Archivable` and `Koi::Model::OTP`, so records can be archived instead of deleted and can enrol in one-time-password MFA. +- **Session enforcement**: `Koi::Middleware::AdminAuthentication` intercepts all `/admin` requests and redirects unauthenticated visitors to `/admin/session/new`, preserving the original path in `flash[:redirect]` for post-login return. +- **Controller helpers**: Every admin controller includes `Koi::Controller`, which exposes `current_admin_user`, `current_admin`, and `admin_signed_in?` to controllers and views (`app/controllers/concerns/koi/controller.rb`). Use these helpers for scoping queries or rendering personalised UI. +- **Authentication features**: Koi supports email/password sign-in, optional TOTP-based MFA, WebAuthn passkeys, magic login links, and a development-only auto-login (`Koi.config.authenticate_local_admins`). Sign-in/out events update `sign_in_count` and timestamps via `Koi::Controller::RecordsAuthentication`. +- **No authorisation layer**: Koi does not ship with policies or role-based access. Projects must layer their own Pundit, CanCan, or bespoke authorisation checks on top of the authenticated admin. + +## Authentication Options + +### Password Sign-in (with optional MFA) + +When an admin submits an email and password, `Admin::SessionsController#create` uses `Admin::User.authenticate_by` for constant-time verification. If `otp_secret` is present (`requires_otp?` returns true), Koi pauses to collect a 6-digit token before completing the login. OTP codes are issued by TOTP apps and verified through `Koi::Model::OTP`. Removing the secret disables MFA immediately. + +### Passkeys (WebAuthn) + +Admins can register WebAuthn credentials from their profile (`/admin/admin_users/:id`). `Admin::CredentialsController` issues a challenge, stores it in the session, and captures device metadata when the OS completes attestation. Subsequent logins call `webauthn_authenticate!`, which verifies the assertion, updates the credential `sign_count`, and signs the admin in passwordlessly. Registration requires `https://` in production; browsers only permit WebAuthn on HTTPS origins or `http://localhost`. + +### Magic Login Links + +`Admin::TokensController` uses `generates_token_for(:password_reset, expires_in: 30.minutes)` to mint short-lived login links. Tokens expire after the first successful sign-in (because the generator ties validity to `current_sign_in_at`). Use these links when inviting new admins or when someone forgets their password. Koi renders the link in the UI; projects are responsible for delivering it (email, chat, etc.). + +### Development Auto-login + +With `Koi.config.authenticate_local_admins` (true by default in development), visiting `/admin` looks up `#{ENV['USER']}@katalyst.com.au` or `admin@katalyst.com.au`, sets `session[:admin_user_id]`, and skips the login form. Disable this behaviour by setting `authenticate_local_admins = false` in `config/initializers/koi.rb` when you need to rehearse the full login flow. + +## Working With Admin Users in Code + +- In controllers, rely on `current_admin_user` for the signed-in admin. The deprecated alias `current_admin` still exists but should be avoided for new code. +- `admin_signed_in?` tells you whether the helper is present; use it to guard navigation elements or to short-circuit actions that require authentication. +- Because `Koi::Controller` is included globally, view components and cells rendered inside admin controllers have access to the same helpers. +- For service objects or background jobs, accept an `Admin::User` (or its `id`) explicitly—there is no global singleton. Persist IDs if you need to audit actions later. +- Specs can include `Koi::Controller::HasAdminUsers::Test::ViewHelper` to stub `current_admin_user` in view specs. +- Remember that Koi only authenticates admins. Front-end customer accounts (if your app has them) must be implemented separately. + +## Provisioning Admin Accounts + +### Seeded Default Admin + +Running `rails db:seed` in a project generated from `koi-template` invokes `Koi::Engine.load_seed`, which creates a single development admin: + +- **Email**: `#{ENV['USER']}@katalyst.com.au` +- **Name**: output of `id -F` +- **Password**: `password` + +Combined with development auto-login, this means you can usually load `/admin` immediately after seeding. + +**Verify** + +```sh +bin/rails runner 'puts Admin::User.pluck(:email, :sign_in_count)' +``` + +Expect to see at least one record with the shell-derived email and `sign_in_count` of 0 or 1. + +### CLI Helpers + +Projects bootstrapped from `koi-template` ship with helper scripts in `bin/`: + +- `bin/admin-adduser [-n "Full Name"] [-e email@example.com]` creates or reuses an admin, printing a login URL. When no `-e` is supplied, the script defaults to `@katalyst.com.au`. +- `bin/admin-reset ` regenerates a login link for an existing admin without modifying passwords. + +Copy these scripts into existing apps or implement equivalent Rake tasks so onboarding is reproducible. + +**Verify** + +```sh +bin/admin-adduser -n "Test Admin" -e "test-admin@example.com" +# paste the emitted /admin/session/token/... URL into a browser and confirm it signs you in +``` + +### Admin UI Workflows + +Navigate to `/admin/admin_users` to manage accounts: + +- **Create**: Click *New admin user*, supply name and email, and submit. Koi creates the record without a password; use *Generate login link* to send them an activation URL. +- **Edit**: Admins can update each other’s names/emails. When editing yourself (`request.variant = :self`), the form also exposes a password field so you can set or change a password. +- **Archive / Restore / Delete**: First archive an admin to revoke access (sets `archived_at`). Archived admins are hidden from default queries; use the *Archived* tab to restore or permanently delete them (hard delete only works from the archived list). +- **Audit**: The show view surfaces `last_sign_in_at`, passkey status, MFA enrolment, and whether the account is archived. + +**Verify** + +```sh +bin/rails runner 'admin = Admin::User.order(:created_at).first; puts({ email: admin.email, archived: admin.archived?, passkeys: admin.credentials.count })' +``` + +Expect `archived` to be `false` for active users and `passkeys` to reflect any registered credentials. + +## Managing Passkeys and MFA + +From an admin’s profile (accessible via the header avatar or `/admin/admin_users/:id`): + +- Choose *New passkey* to open the WebAuthn registration dialog. The browser prompts for a device, and Koi stores the resulting credential in `admin_credentials`. You can name each credential to track which device it belongs to and remove stale ones individually. +- Choose *Add* under MFA to start enrolment. Koi pre-generates an `otp_secret`, renders a QR code (via `RQRCode`), and asks for a verification token. Once saved, future password logins require the 6-digit code. Removing MFA deletes the secret immediately. + +Passkeys are the recommended option when available; MFA is a good fallback for browsers or devices that cannot store passkeys. + +## Inviting Colleagues and Handling Approvals + +There is no separate approval workflow. To onboard someone: + +1. Create the admin via the CLI or `/admin/admin_users/new`. +2. On the admin’s show page, click *Generate login link*. This hits `Admin::TokensController#create` and renders a copyable `/admin/session/token/` URL. +3. Share the link over a secure channel. The recipient visits the link, reviews their details, and clicks *Sign in* to consume the token. +4. Encourage them to add a passkey (preferred) or set a password and MFA once signed in. + +Tokens expire 30 minutes after generation or immediately after use. If a token expires before it is consumed, issue a new one from the same page or by running `bin/admin-reset`. + +## Automatic Login and Redirects in Development + +With auto-login enabled, Koi tries to sign you in using the seeded email. If the admin record no longer matches, you will be redirected to `/admin/session/new` repeatedly. + +### Avoiding the Redirect Loop + +- **Add a matching account first**: + ```sh + bin/admin-adduser -n "Your Name" -e "#{ENV['USER']}@katalyst.com.au" + ``` +- **Or disable auto-login temporarily** by setting `Koi.configure { |config| config.authenticate_local_admins = false }` in `config/initializers/koi.rb` and restarting Rails. Re-enable it once emails are aligned. + +### Recovering if You’re Already Stuck + +1. Run `bin/admin-adduser -n "Your Name" -e "#{ENV['USER']}@katalyst.com.au"` to recreate the expected account. +2. Refresh `/admin`; auto-login should succeed again. +3. Archive or rename the extra account only after adjusting auto-login behaviour. + +## Additional Gotchas + +- **HTTPS for passkeys**: Browsers require HTTPS (or `localhost`) for WebAuthn. Use a reverse proxy such as `ngrok` when demonstrating passkeys over the public internet. +- **Token longevity**: Login links expire after 30 minutes or once used. Automations that email links should warn recipients about the short window. +- **Archiving keeps passkeys**: Archiving does not purge passkeys or MFA secrets. Restoring an admin reactivates those credentials; delete them explicitly if you need them revoked. + +## Verification Checklist + +Run these checkpoints before handing control to someone else: + +- `bin/rails runner 'puts Admin::User.count'` shows at least one active admin. +- Visiting `/admin` in development signs you in automatically **or** prompts for credentials when auto-login is disabled. +- `/admin/admin_users` lists your account, and the show page offers *Generate login link*. +- Clicking *New passkey* prompts the browser for a device (on HTTPS or localhost). +- MFA enrolment succeeds by scanning the QR code and entering the generated token. +- `bin/admin-adduser -e alternate@example.com` prints a usable magic-link URL. From 1b791a19c7e3fd850964983a9a4da79e6db08cc1 Mon Sep 17 00:00:00 2001 From: Jason Sidoryn Date: Tue, 23 Sep 2025 09:37:31 +0930 Subject: [PATCH 3/4] archiving and content documentation --- docs/admin-module-workflow.md | 2 + docs/archiving.md | 66 ++++++++++++ docs/content.md | 198 ++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 docs/archiving.md create mode 100644 docs/content.md diff --git a/docs/admin-module-workflow.md b/docs/admin-module-workflow.md index b3f3791c..b1d69ec3 100644 --- a/docs/admin-module-workflow.md +++ b/docs/admin-module-workflow.md @@ -125,6 +125,8 @@ Including `Koi::Model::Archivable` in the model adds the `archived_at` scope and - The index view adds a bulk archive action and a link to the archived list (`lib/generators/koi/admin_views/templates/index.html.erb.tt:21`). - Destroy actions archive first, then delete once already archived (`lib/generators/koi/admin_controller/templates/controller.rb.tt:64`). +Finish the setup by following the dedicated archiving guide (`archiving.md`) for form wiring, strong parameters, UI surfacing, and testing expectations. That guide captures the manual steps discovered while building the Pages module. + ## Navigation Registration Every admin module is added to `Koi::Menu.modules` with a label derived from the namespace and model name. The generator rewrites the initializer block, keeping entries alphabetical (`lib/generators/koi/admin_route/admin_route_generator.rb:46`). Move the item into groups or submenus by editing `config/initializers/koi.rb` after generation. Because this lives in an initializer, restart the app (or reload Spring) before expecting the navigation dialog to pick up changes. diff --git a/docs/archiving.md b/docs/archiving.md new file mode 100644 index 00000000..2d12251c --- /dev/null +++ b/docs/archiving.md @@ -0,0 +1,66 @@ +# Archiving Modules in Koi + +Use this guide whenever you scaffold or retrofit a module that supports soft deletion via `archived_at`. It summarises the conventions baked into Koi, the gaps left by generators, and the post-generation steps required to give editors a predictable UI. + +## What the Generators Provide + +When the schema contains an `archived_at` column _and_ the model mixes in `Koi::Model::Archivable` (add the concern before running `koi:admin`, or rerun the generator afterward so it picks up the capability): + +- `koi:model` inserts the concern include and keeps `archived_at` in the migration. +- `koi:admin` generates: + - Bulk archive tooling on the index (`table_selection_with` + `Archive` button) and an `Archived` tab. + - Controller actions `archive`, `restore`, and `archived`, plus the `destroy` behaviour that archives first, then hard-deletes once already archived. + - Routes and menu entries for the additional actions. +- Default scopes become `not_archived`, so new records stay visible unless explicitly archived. + +## What You Still Need to Wire Manually + +1. **Permit the toggle** – add `:archived` to strong parameters in the admin controller so the form checkbox can post back: + ```ruby + def page_params + params.expect(page: [ + :title, + :published_on, + :archived_at, + :archived, + { items_attributes: [%i[id index depth]] }, + ]) + end + ``` + +2. **Expose the checkbox** – surface the boolean on both new/edit forms so editors can archive or restore a single record without navigating away: + ```erb + <%= form.govuk_check_box_field :archived %> + ``` + The concern maps the boolean to the soft-delete helper (`true` ⇒ archive, `false` ⇒ restore). Because new records default to unchecked, they start unarchived. + +3. **Surface state in the UI** – include `row.boolean :archived` (or `:archived?`) in summary tables so admins can see the flag on the show screen. This is optional but recommended for clarity. + +4. **Tests** – extend the request spec to cover: + - Bulk archive & restore actions (`PUT /archive`, `PUT /restore`). + - Destroy behaviour (archives first, then deletes when already archived). + - Form-driven archiving via the checkbox on create/update. + Use `Page.archived` / `Page.not_archived` to assert state changes. + +5. **Content status bar links** – if the module uses Katalyst::Content, make sure you expose matching public routes so the editor’s status bar can link to the live and preview versions. Add something like: + ```ruby + resources :pages, only: :show do + get :preview, on: :member + end + ``` + Then render the published/draft versions in a controller (`render_content(page.published_version)` / `page.draft_version`) so `url_for(container)` resolves correctly. Without these routes, the status bar raises “undefined method `page_path`” and its links break. + +## Typical Admin Workflow + +1. **Archive from the index** – select rows using the checkboxes, click `Archive`, confirm the toast, and verify they disappear from the active index. +2. **Restore from the archived tab** – open `Archived`, select records, click `Restore`. +3. **Archive from the form** – open a record, tick “Archived”, save. The index removes it (default scope). Editing again and unticking restores it. +4. **Permanent delete** – once a record is archived, hitting the destroy action (via index bulk delete or form archive again) removes it entirely. + +## Recommended Follow-up Checks + +- Ensure the menu entry still surfaces the module under `Koi::Menu.modules`. +- If the module exposes public routes, verify archived records fall out of default public scopes (because of the default scope). +- Run `bundle exec rspec` for the request specs touching archive flows. + +Keep this guide close when adding new archivable modules so future changes stay consistent with Koi’s soft-delete semantics. diff --git a/docs/content.md b/docs/content.md new file mode 100644 index 00000000..2cf8d97e --- /dev/null +++ b/docs/content.md @@ -0,0 +1,198 @@ +# Working With Koi Content + +## Overview +Koi ships with the [Katalyst::Content] engine to deliver block-based, draftable page content. It gives editors a drag-and-drop authoring surface, versioning (draft/publish/revert), media-rich blocks, and theme-aware rendering, all inside the familiar Koi admin experience. Typical use cases include: + +- marketing or landing pages that need reusable layouts without code changes; +- knowledge bases or documentation hubs where editors publish revisions safely; +- feature pages that mix text, media, and tabular data while respecting site branding. + +Because the engine is mounted under `/admin/content`, Koi automatically loads the Stimulus controllers, styles, and error components the editor requires. Once a model opts-in to `Katalyst::Content::Container`, Koi’s admin controllers and helpers provide an end-to-end UI for drafting, previewing, and publishing content. + +### Authoring Experience +From an editor’s perspective the content screen provides: + +- **Block library** – layouts (sections, groups, columns, asides) and content blocks (rich text, figures, tables) are available from the “Add item” dialog or inline insert handle. +- **Tree + drag-and-drop** – reorder, indent, collapse, or remove blocks with guard rails supplied by the rules engine (e.g. only layout blocks can contain children). +- **Inline previews** – open any block in a modal form to edit fields; tables offer a WYSIWYG grid, and figures support direct uploads. +- **Draft workflow** – the status bar shows published/draft state and surfaces `Save`, `Publish`, `Revert`, and `Discard` buttons. Unsaved edits flag the status as “Unsaved changes”. +- **Safe publishing** – publishing promotes the draft version to live content, while revert snaps back to the last publication. Hidden blocks stay out of the frontend until re-enabled. + +## Workflow: Adding Content To A Module (Pages Example) +This walkthrough assumes you already have a `Page` model with title/slug attributes and an admin module scaffolded in Koi. + +1. **Install migrations** + - run `bin/rails katalyst_content:install:migrations` + - apply the generated migrations plus the Page-specific version table: + ```ruby + class CreatePageVersions < ActiveRecord::Migration[7.0] + def change + create_table :page_versions do |t| + t.references :parent, null: false, foreign_key: { to_table: :pages } + t.json :nodes + t.timestamps + end + + change_table :pages do |t| + t.references :published_version, foreign_key: { to_table: :page_versions } + t.references :draft_version, foreign_key: { to_table: :page_versions } + end + end + end + ``` + - **Verification:** `bin/rails db:migrate` succeeds and `rails db:schema:dump` shows `katalyst_content_items` plus `page_versions` and the extra foreign keys on `pages`. + +2. **Update the model** + ```ruby + # app/models/page.rb + class Page < ApplicationRecord + include Katalyst::Content::Container + + class Version < ApplicationRecord + include Katalyst::Content::Version + end + end + ``` + - **Verification:** `bin/rails console` – instantiate a page (`page = Page.new`) and set an items payload (`page.items_attributes = []`). `page.draft_version` should now return a version object, while `page.publish!` still raises because nothing is persisted yet. Assigning `items_attributes` (even an empty array) mimics the editor’s requests and is required before the container builds versions. + +3. **Expose content in the admin controller** + Adapt your admin controller to match the dummy implementation: + ```ruby + class Admin::PagesController < Admin::ApplicationController + helper Katalyst::Content::EditorHelper + + def show + page = Page.find(params[:id]) + editor = Katalyst::Content::EditorComponent.new(container: page) + render locals: { page:, editor: } + end + + def update + page = Page.find(params[:id]) + page.attributes = page_params + + unless page.valid? + editor = Katalyst::Content::EditorComponent.new(container: page) + respond_to do |format| + format.turbo_stream { render editor.errors, status: :unprocessable_entity } + end + return + end + + case params[:commit] + when "publish" then page.save! && page.publish! + when "revert" then page.revert! + else page.save! + end + + redirect_to [:admin, page], status: :see_other + end + + private + + def page_params + params.expect(page: [:title, :slug, { items_attributes: [%i[id index depth]] }]) + end + end + ``` + - **Verification:** open `/admin/pages/:id` in a browser; the status bar should render and the add-item dialog should function. Submitting invalid data should render the inline error frame supplied by `Koi::Content::Editor::ErrorsComponent`. + +4. **Render the editor in the view** + ```erb + + <%= render editor.status_bar %> + + <%= render editor do |editor_component| %> + <% editor_component.with_new_items do |component| %> +

Layouts

+
    + <%= component.item(:section) %> + <%= component.item(:group) %> + <%= component.item(:column) %> + <%= component.item(:aside) %> +
+

Content

+
    + <%= component.item(:content) %> + <%= component.item(:figure) %> + <%= component.item(:table) %> +
+ <% end %> + <% end %> + ``` + - **Verification:** refreshing the page should show the grouped block library; dragging blocks should update the tree without validation errors. + +5. **Expose frontend rendering** + - In public controllers use `render_content(page.published_version)` (or `draft_version` for previews). + - Ensure your frontend layout imports `/katalyst/content/frontend.css` if you are not already using Koi’s defaults. + - Add a standard show/preview route so the editor status bar can link to the live and draft versions: + + ```ruby + # config/routes.rb + resources :pages, only: :show do + get :preview, on: :member + end + + # app/controllers/pages_controller.rb + class PagesController < ApplicationController + helper Katalyst::Content::FrontendHelper + + before_action :set_page + + def show + render locals: { page: @page, version: @page.published_version || fallback_version } + end + + def preview + render :show, locals: { page: @page, version: @page.draft_version || fallback_version } + end + + private + + def set_page + scope = action_name == "preview" ? Page.with_archived : Page.not_archived + @page = scope.find(params[:id]) + end + + def fallback_version + @page.draft_version || @page.build_draft_version + end + end + ``` + + - **Verification:** visit the public page and confirm blocks render respecting heading styles, themes, and visibility toggles. The status bar’s “Published/Preview” links should now resolve without overrides. + +## Built-in Content Types +Koi registers the default block set from the engine (`Katalyst::Content.config.items`): + +| Block | Purpose | +| --- | --- | +| `Section` | Top-level layout with heading + child flow. | +| `Group` | Secondary grouping container. | +| `Column` | Two-column wrapper; splits children between columns. | +| `Aside` | Content + sidebar layout with optional mobile reversal. | +| `Content` | Rich-text body via Action Text (supports attachments). | +| `Figure` | Image with alt text and optional caption; enforces file type/size limits. | +| `Table` | Sanitised HTML table with heading row/column helpers and toolbar for paste/clean-up. | + +To extend the library: + +1. Create a subclass of `Katalyst::Content::Item` (or `Layout`) with any custom attributes via `style_attributes` and partials under `app/views/katalyst/content//`. +2. Append the class name to `Katalyst::Content.config.items` (typically in an initializer) so the editor exposes it in the block picker. +3. Provide a form partial (`_your_type.html+form.erb`) and a render partial (`_your_type.html.erb`). + +A follow-up document will dive deeper into block authoring and theming. + +## Internals & Reference (for Humans and LLM Agents) +This section summarises the architecture uncovered during investigation. It is intentionally explicit so maintainers and coding agents can script changes safely. + +- **Data model**: `katalyst_content_items` stores polymorphic blocks. Containers keep draft/published foreign keys and delegate to `Version` models that serialise `{id, depth, index}` nodes through `Katalyst::Content::Types::NodesType`. Garbage collection (`Content::GarbageCollection`) removes stale versions and items two hours after they fall out of active drafts. +- **State machine**: The `Container` concern derives `state` (`unpublished`, `draft`, `published`) from whether draft/published references match. `publish!` replaces the published pointer with the current draft; `revert!` resets drafts back to published; `unpublish!` clears publication altogether. +- **Editor stack**: `EditorComponent` assembles the form, status bar, block table, and `new_items` slot. `Editor::TableComponent` wraps the block list with Stimulus controllers (`content--editor--list`, `content--editor--container`) to handle drag/drop and structural updates. Turbo frames (`content--editor--item-editor`) host block forms, and errors render through `Katalyst::Content.config.errors_component` (overridden by Koi to use `Koi::FormBuilder`). +- **Stimulus controllers**: Found under `content/app/javascript/content/editor/`. Key controllers include `container_controller` (tree state, rule enforcement), `rules_engine` (validation + permission toggles), `item_editor_controller` (modal lifecycle), `table_controller` (contenteditable facade for tables), and `trix_controller` (Action Text compatibility patch). +- **Routes & controllers**: Engine routes expose `items` and `tables` resources plus `direct_uploads#create`. `ItemsController` dynamic-dispatches permitted params based on `config.items`, duplicates records on update to keep history, and pre-saves attachments to avoid data loss when validations fail. +- **Rendering helpers**: `render_content(version)` groups visible items by theme, wraps them in `.content-items` with data attributes (`content_item_tag`), and caches by version record. Layout partials call back into `render_content_items` for child trees. +- **Configuration hooks**: `Katalyst::Content.config` influences available themes, heading styles, block classes, max upload size, table sanitiser allow-list, and which error component/controller base to use. Koi’s engine initializer sets `base_controller` to `Admin::ApplicationController` and swaps the error component to Koi’s GOV.UK-flavoured implementation. +- **Testing surface**: The engine ships a dummy app demonstrating the full integration (`spec/dummy/app/...`) and extensive model specs for blocks and container behaviour. When automating changes, follow the patterns in `spec/models/page_spec.rb` to assert version transitions and item validation. + +Use this reference when automating editor customisations, generating documentation, or writing migrations—each bullet maps to concrete files in the `content/` engine, making it safe for agents to reason about entry points. From 2453e657a7bb9137baf99e7c185bf48dbb99b976 Mon Sep 17 00:00:00 2001 From: Jason Sidoryn Date: Wed, 24 Sep 2025 08:30:28 +0930 Subject: [PATCH 4/4] Document root-level page routing pattern --- docs/admin-module-workflow.md | 1 + docs/archiving.md | 3 +- docs/content.md | 380 +++++++++++++++++++++----------- docs/feedback.md | 0 docs/koi-user-guide.md | 3 + docs/root-level-page-routing.md | 131 +++++++++++ 6 files changed, 390 insertions(+), 128 deletions(-) create mode 100644 docs/feedback.md create mode 100644 docs/root-level-page-routing.md diff --git a/docs/admin-module-workflow.md b/docs/admin-module-workflow.md index b1d69ec3..98d07d5a 100644 --- a/docs/admin-module-workflow.md +++ b/docs/admin-module-workflow.md @@ -141,6 +141,7 @@ Every admin module is added to `Koi::Menu.modules` with a label derived from the - **Override form layouts** using ViewComponents or partials if you need multi-column layouts—just ensure the submit buttons continue to call `form.admin_save` so styles remain consistent. - **Additional actions** go inside the controller and can be surfaced in the header via `actions_list`. - **Non-standard inputs** (e.g., slug sync, toggles) can hook into existing Stimulus controllers such as `sluggable` or `show-hide`. +- **Front-end routes** – when marketing pages should appear at `/slug` instead of `/pages/slug`, use the [`root-level-page-routing.md`](./root-level-page-routing.md) constraint pattern after scaffolding the public controller. ## Generator Reference diff --git a/docs/archiving.md b/docs/archiving.md index 2d12251c..01695709 100644 --- a/docs/archiving.md +++ b/docs/archiving.md @@ -6,7 +6,7 @@ Use this guide whenever you scaffold or retrofit a module that supports soft del When the schema contains an `archived_at` column _and_ the model mixes in `Koi::Model::Archivable` (add the concern before running `koi:admin`, or rerun the generator afterward so it picks up the capability): -- `koi:model` inserts the concern include and keeps `archived_at` in the migration. +- `koi:model` keeps `archived_at` in the migration but, as of Koi 5.0.3, does **not** insert `include Koi::Model::Archivable`; add the concern manually (before running `koi:admin`) so the admin generator can detect the capability. - `koi:admin` generates: - Bulk archive tooling on the index (`table_selection_with` + `Archive` button) and an `Archived` tab. - Controller actions `archive`, `restore`, and `archived`, plus the `destroy` behaviour that archives first, then hard-deletes once already archived. @@ -49,6 +49,7 @@ When the schema contains an `archived_at` column _and_ the model mixes in `Koi:: end ``` Then render the published/draft versions in a controller (`render_content(page.published_version)` / `page.draft_version`) so `url_for(container)` resolves correctly. Without these routes, the status bar raises “undefined method `page_path`” and its links break. + If the site needs `/slug` URLs without the `/pages` prefix, layer on the constraint workflow in [`root-level-page-routing.md`](./root-level-page-routing.md). ## Typical Admin Workflow diff --git a/docs/content.md b/docs/content.md index 2cf8d97e..569f0a5a 100644 --- a/docs/content.md +++ b/docs/content.md @@ -1,46 +1,60 @@ # Working With Koi Content ## Overview -Koi ships with the [Katalyst::Content] engine to deliver block-based, draftable page content. It gives editors a drag-and-drop authoring surface, versioning (draft/publish/revert), media-rich blocks, and theme-aware rendering, all inside the familiar Koi admin experience. Typical use cases include: + +Koi ships with the [Katalyst::Content] engine to deliver block-based, draftable page content. It gives editors a +drag-and-drop authoring surface, versioning (draft/publish/revert), media-rich blocks, and theme-aware rendering, all +inside the familiar Koi admin experience. Typical use cases include: - marketing or landing pages that need reusable layouts without code changes; - knowledge bases or documentation hubs where editors publish revisions safely; - feature pages that mix text, media, and tabular data while respecting site branding. -Because the engine is mounted under `/admin/content`, Koi automatically loads the Stimulus controllers, styles, and error components the editor requires. Once a model opts-in to `Katalyst::Content::Container`, Koi’s admin controllers and helpers provide an end-to-end UI for drafting, previewing, and publishing content. +Because the engine is mounted under `/admin/content`, Koi automatically loads the Stimulus controllers, styles, and +error components the editor requires. Once a model opts-in to `Katalyst::Content::Container`, Koi’s admin controllers +and helpers provide an end-to-end UI for drafting, previewing, and publishing content. ### Authoring Experience + From an editor’s perspective the content screen provides: -- **Block library** – layouts (sections, groups, columns, asides) and content blocks (rich text, figures, tables) are available from the “Add item” dialog or inline insert handle. -- **Tree + drag-and-drop** – reorder, indent, collapse, or remove blocks with guard rails supplied by the rules engine (e.g. only layout blocks can contain children). -- **Inline previews** – open any block in a modal form to edit fields; tables offer a WYSIWYG grid, and figures support direct uploads. -- **Draft workflow** – the status bar shows published/draft state and surfaces `Save`, `Publish`, `Revert`, and `Discard` buttons. Unsaved edits flag the status as “Unsaved changes”. -- **Safe publishing** – publishing promotes the draft version to live content, while revert snaps back to the last publication. Hidden blocks stay out of the frontend until re-enabled. +- **Block library** – layouts (sections, groups, columns, asides) and content blocks (rich text, figures, tables) are + available from the “Add item” dialog or inline insert handle. +- **Tree + drag-and-drop** – reorder, indent, collapse, or remove blocks with guard rails supplied by the rules engine ( + e.g. only layout blocks can contain children). +- **Inline previews** – open any block in a modal form to edit fields; tables offer a WYSIWYG grid, and figures support + direct uploads. +- **Draft workflow** – the status bar shows published/draft state and surfaces `Save`, `Publish`, `Revert`, and + `Discard` buttons. Unsaved edits flag the status as “Unsaved changes”. +- **Safe publishing** – publishing promotes the draft version to live content, while revert snaps back to the last + publication. Hidden blocks stay out of the frontend until re-enabled. ## Workflow: Adding Content To A Module (Pages Example) -This walkthrough assumes you already have a `Page` model with title/slug attributes and an admin module scaffolded in Koi. + +This walkthrough assumes you already have a `Page` model with title/slug attributes and an admin module scaffolded in +Koi. 1. **Install migrations** - - run `bin/rails katalyst_content:install:migrations` - - apply the generated migrations plus the Page-specific version table: - ```ruby - class CreatePageVersions < ActiveRecord::Migration[7.0] - def change - create_table :page_versions do |t| - t.references :parent, null: false, foreign_key: { to_table: :pages } - t.json :nodes - t.timestamps - end - - change_table :pages do |t| - t.references :published_version, foreign_key: { to_table: :page_versions } - t.references :draft_version, foreign_key: { to_table: :page_versions } - end - end - end - ``` - - **Verification:** `bin/rails db:migrate` succeeds and `rails db:schema:dump` shows `katalyst_content_items` plus `page_versions` and the extra foreign keys on `pages`. + - run `bin/rails katalyst_content:install:migrations` + - apply the generated migrations plus the Page-specific version table: + ```ruby + class CreatePageVersions < ActiveRecord::Migration[7.0] + def change + create_table :page_versions do |t| + t.references :parent, null: false, foreign_key: { to_table: :pages } + t.json :nodes + t.timestamps + end + + change_table :pages do |t| + t.references :published_version, foreign_key: { to_table: :page_versions } + t.references :draft_version, foreign_key: { to_table: :page_versions } + end + end + end + ``` + - **Verification:** `bin/rails db:migrate` succeeds and `rails db:schema:dump` shows `katalyst_content_items` plus + `page_versions` and the extra foreign keys on `pages`. 2. **Update the model** ```ruby @@ -53,49 +67,73 @@ This walkthrough assumes you already have a `Page` model with title/slug attribu end end ``` - - **Verification:** `bin/rails console` – instantiate a page (`page = Page.new`) and set an items payload (`page.items_attributes = []`). `page.draft_version` should now return a version object, while `page.publish!` still raises because nothing is persisted yet. Assigning `items_attributes` (even an empty array) mimics the editor’s requests and is required before the container builds versions. + - **Verification:** `bin/rails console` – instantiate a page (`page = Page.new`) and set an items payload ( + `page.items_attributes = []`). `page.draft_version` should now return a version object, while `page.publish!` + still raises because nothing is persisted yet. Assigning `items_attributes` (even an empty array) mimics the + editor’s requests and is required before the container builds versions. 3. **Expose content in the admin controller** Adapt your admin controller to match the dummy implementation: ```ruby - class Admin::PagesController < Admin::ApplicationController - helper Katalyst::Content::EditorHelper - - def show - page = Page.find(params[:id]) - editor = Katalyst::Content::EditorComponent.new(container: page) - render locals: { page:, editor: } - end - - def update - page = Page.find(params[:id]) - page.attributes = page_params - - unless page.valid? - editor = Katalyst::Content::EditorComponent.new(container: page) - respond_to do |format| - format.turbo_stream { render editor.errors, status: :unprocessable_entity } - end - return - end - - case params[:commit] - when "publish" then page.save! && page.publish! - when "revert" then page.revert! - else page.save! - end - - redirect_to [:admin, page], status: :see_other - end - - private - - def page_params - params.expect(page: [:title, :slug, { items_attributes: [%i[id index depth]] }]) - end - end + module Admin + class PagesController < Admin::ApplicationController + helper Katalyst::Content::EditorHelper + + attr_reader :page + + before_action :set_page + + def show + render locals: { page:, editor: Katalyst::Content::EditorComponent.new(container: page) } + end + + def update + page.assign_attributes(page_params) + + unless page.valid? + case previous_action + when "show" + # content cannot be saved, replace error frame + editor = Katalyst::Content::EditorComponent.new(container: page) + return respond_to do |format| + format.turbo_stream { render editor.errors, status: :unprocessable_content } + end + when "edit" + # form errors, re-render edit + return render previous_action, locals: { page: }, status: :unprocessable_content + end + end + + case params[:commit] + when "publish" + page.save! + page.publish! + when "save" + page.save! + when "revert" + page.revert! + end + + return redirect_to admin_page_path(page), status: :see_other if previous_action == "edit" + + redirect_back_or_to(admin_page_path(page), status: :see_other) + end + + private + + def set_page + @page = ::Page.find(params[:id]) + end + + def page_params + params.expect(page: [:title, :slug, { items_attributes: [%i[id index depth]] }]) + end + end + end ``` - - **Verification:** open `/admin/pages/:id` in a browser; the status bar should render and the add-item dialog should function. Submitting invalid data should render the inline error frame supplied by `Koi::Content::Editor::ErrorsComponent`. + - **Verification:** open `/admin/pages/:id` in a browser; the status bar should render and the add-item dialog + should function. Submitting invalid data should render the inline error frame supplied by + `Koi::Content::Editor::ErrorsComponent`. 4. **Render the editor in the view** ```erb @@ -120,79 +158,167 @@ This walkthrough assumes you already have a `Page` model with title/slug attribu <% end %> <% end %> ``` - - **Verification:** refreshing the page should show the grouped block library; dragging blocks should update the tree without validation errors. + - **Verification:** refreshing the page should show the grouped block library; dragging blocks should update the + tree without validation errors. 5. **Expose frontend rendering** - - In public controllers use `render_content(page.published_version)` (or `draft_version` for previews). - - Ensure your frontend layout imports `/katalyst/content/frontend.css` if you are not already using Koi’s defaults. - - Add a standard show/preview route so the editor status bar can link to the live and draft versions: - - ```ruby - # config/routes.rb - resources :pages, only: :show do - get :preview, on: :member - end - - # app/controllers/pages_controller.rb - class PagesController < ApplicationController - helper Katalyst::Content::FrontendHelper - - before_action :set_page - - def show - render locals: { page: @page, version: @page.published_version || fallback_version } - end - - def preview - render :show, locals: { page: @page, version: @page.draft_version || fallback_version } - end - - private - - def set_page - scope = action_name == "preview" ? Page.with_archived : Page.not_archived - @page = scope.find(params[:id]) - end - - def fallback_version - @page.draft_version || @page.build_draft_version - end - end - ``` - - - **Verification:** visit the public page and confirm blocks render respecting heading styles, themes, and visibility toggles. The status bar’s “Published/Preview” links should now resolve without overrides. + - In public controllers use `render_content(page.published_version)` (or `draft_version` for previews). + - Ensure your frontend layout imports `/katalyst/content/frontend.css` if you are not already using Koi’s defaults. + - Add a standard show/preview route so the editor status bar can link to the live and draft versions: + + ```ruby + # config/routes.rb + resources :pages, only: :show do + get :preview, on: :member + end + + constraints PagesController::Constraints do + resources :pages, path: "", only: [:show], param: :slug, constraints: { format: :html } do + get "preview", on: :member + end + end + + resolve("Page") { |page, options = {}| [:page, { slug: page.slug, **options }] } + + # app/controllers/pages_controller.rb + class PagesController < ApplicationController + helper Katalyst::Content::FrontendHelper + + def show + render locals: { page:, version: page.published_version } + end + + def preview + return redirect_to action: :show if page.state == "published" + + render :show, locals: { page:, version: page.draft_version } + end + + def seo_metadatum + page.seo_metadatum + end + + private + + def page + if request.has_header?("katalyst.matched.page") + request.get_header("katalyst.matched.page") + else + Page.find_by!(slug: params[:slug]) + end + end + + class Constraints + attr_reader :request + + def self.matches?(request) + new(request).match? + end + + def initialize(request) + @request = request + end + + # Implement constraints API + def match? + request.set_header("katalyst.matched.page", page) + + return false if page.blank? + + case action + when "show" + page.published? + when "preview" + admin? + else + false + end + end + + def action + request.params[:action] + end + + def admin? + request.session[:admin_user_id].present? + end + + def page + nil unless request.params[:slug] && request.get? + + if defined?(@page) + @page + else + @page = Page.find_by(slug: request.params[:slug]) + end + end + end + end + ``` + + - **Verification:** visit the public page and confirm blocks render respecting heading styles, themes, and + visibility toggles. The status bar’s “Published/Preview” links should now resolve without overrides. + - **Need root-level slugs?** Follow the [`root-level-page-routing.md`](./root-level-page-routing.md) pattern to + serve published pages at `/slug` while keeping previews admin-only. ## Built-in Content Types + Koi registers the default block set from the engine (`Katalyst::Content.config.items`): -| Block | Purpose | -| --- | --- | -| `Section` | Top-level layout with heading + child flow. | -| `Group` | Secondary grouping container. | -| `Column` | Two-column wrapper; splits children between columns. | -| `Aside` | Content + sidebar layout with optional mobile reversal. | -| `Content` | Rich-text body via Action Text (supports attachments). | -| `Figure` | Image with alt text and optional caption; enforces file type/size limits. | -| `Table` | Sanitised HTML table with heading row/column helpers and toolbar for paste/clean-up. | +| Block | Purpose | +|-----------|--------------------------------------------------------------------------------------| +| `Section` | Top-level layout with heading + child flow. | +| `Group` | Secondary grouping container. | +| `Column` | Two-column wrapper; splits children between columns. | +| `Aside` | Content + sidebar layout with optional mobile reversal. | +| `Content` | Rich-text body via Action Text (supports attachments). | +| `Figure` | Image with alt text and optional caption; enforces file type/size limits. | +| `Table` | Sanitised HTML table with heading row/column helpers and toolbar for paste/clean-up. | To extend the library: -1. Create a subclass of `Katalyst::Content::Item` (or `Layout`) with any custom attributes via `style_attributes` and partials under `app/views/katalyst/content//`. -2. Append the class name to `Katalyst::Content.config.items` (typically in an initializer) so the editor exposes it in the block picker. +1. Create a subclass of `Katalyst::Content::Item` (or `Layout`) with any custom attributes via `style_attributes` and + partials under `app/views/katalyst/content//`. +2. Append the class name to `Katalyst::Content.config.items` (typically in an initializer) so the editor exposes it in + the block picker. 3. Provide a form partial (`_your_type.html+form.erb`) and a render partial (`_your_type.html.erb`). A follow-up document will dive deeper into block authoring and theming. ## Internals & Reference (for Humans and LLM Agents) -This section summarises the architecture uncovered during investigation. It is intentionally explicit so maintainers and coding agents can script changes safely. - -- **Data model**: `katalyst_content_items` stores polymorphic blocks. Containers keep draft/published foreign keys and delegate to `Version` models that serialise `{id, depth, index}` nodes through `Katalyst::Content::Types::NodesType`. Garbage collection (`Content::GarbageCollection`) removes stale versions and items two hours after they fall out of active drafts. -- **State machine**: The `Container` concern derives `state` (`unpublished`, `draft`, `published`) from whether draft/published references match. `publish!` replaces the published pointer with the current draft; `revert!` resets drafts back to published; `unpublish!` clears publication altogether. -- **Editor stack**: `EditorComponent` assembles the form, status bar, block table, and `new_items` slot. `Editor::TableComponent` wraps the block list with Stimulus controllers (`content--editor--list`, `content--editor--container`) to handle drag/drop and structural updates. Turbo frames (`content--editor--item-editor`) host block forms, and errors render through `Katalyst::Content.config.errors_component` (overridden by Koi to use `Koi::FormBuilder`). -- **Stimulus controllers**: Found under `content/app/javascript/content/editor/`. Key controllers include `container_controller` (tree state, rule enforcement), `rules_engine` (validation + permission toggles), `item_editor_controller` (modal lifecycle), `table_controller` (contenteditable facade for tables), and `trix_controller` (Action Text compatibility patch). -- **Routes & controllers**: Engine routes expose `items` and `tables` resources plus `direct_uploads#create`. `ItemsController` dynamic-dispatches permitted params based on `config.items`, duplicates records on update to keep history, and pre-saves attachments to avoid data loss when validations fail. -- **Rendering helpers**: `render_content(version)` groups visible items by theme, wraps them in `.content-items` with data attributes (`content_item_tag`), and caches by version record. Layout partials call back into `render_content_items` for child trees. -- **Configuration hooks**: `Katalyst::Content.config` influences available themes, heading styles, block classes, max upload size, table sanitiser allow-list, and which error component/controller base to use. Koi’s engine initializer sets `base_controller` to `Admin::ApplicationController` and swaps the error component to Koi’s GOV.UK-flavoured implementation. -- **Testing surface**: The engine ships a dummy app demonstrating the full integration (`spec/dummy/app/...`) and extensive model specs for blocks and container behaviour. When automating changes, follow the patterns in `spec/models/page_spec.rb` to assert version transitions and item validation. - -Use this reference when automating editor customisations, generating documentation, or writing migrations—each bullet maps to concrete files in the `content/` engine, making it safe for agents to reason about entry points. + +This section summarises the architecture uncovered during investigation. It is intentionally explicit so maintainers and +coding agents can script changes safely. + +- **Data model**: `katalyst_content_items` stores polymorphic blocks. Containers keep draft/published foreign keys and + delegate to `Version` models that serialise `{id, depth, index}` nodes through `Katalyst::Content::Types::NodesType`. + Garbage collection (`Content::GarbageCollection`) removes stale versions and items two hours after they fall out of + active drafts. +- **State machine**: The `Container` concern derives `state` (`unpublished`, `draft`, `published`) from whether + draft/published references match. `publish!` replaces the published pointer with the current draft; `revert!` resets + drafts back to published; `unpublish!` clears publication altogether. +- **Editor stack**: `EditorComponent` assembles the form, status bar, block table, and `new_items` slot. + `Editor::TableComponent` wraps the block list with Stimulus controllers (`content--editor--list`, + `content--editor--container`) to handle drag/drop and structural updates. Turbo frames ( + `content--editor--item-editor`) host block forms, and errors render through + `Katalyst::Content.config.errors_component` (overridden by Koi to use `Koi::FormBuilder`). +- **Stimulus controllers**: Found under `content/app/javascript/content/editor/`. Key controllers include + `container_controller` (tree state, rule enforcement), `rules_engine` (validation + permission toggles), + `item_editor_controller` (modal lifecycle), `table_controller` (contenteditable facade for tables), and + `trix_controller` (Action Text compatibility patch). +- **Routes & controllers**: Engine routes expose `items` and `tables` resources plus `direct_uploads#create`. + `ItemsController` dynamic-dispatches permitted params based on `config.items`, duplicates records on update to keep + history, and pre-saves attachments to avoid data loss when validations fail. +- **Rendering helpers**: `render_content(version)` groups visible items by theme, wraps them in `.content-items` with + data attributes (`content_item_tag`), and caches by version record. Layout partials call back into + `render_content_items` for child trees. +- **Configuration hooks**: `Katalyst::Content.config` influences available themes, heading styles, block classes, max + upload size, table sanitiser allow-list, and which error component/controller base to use. Koi’s engine initializer + sets `base_controller` to `Admin::ApplicationController` and swaps the error component to Koi’s GOV.UK-flavoured + implementation. +- **Testing surface**: The engine ships a dummy app demonstrating the full integration (`spec/dummy/app/...`) and + extensive model specs for blocks and container behaviour. When automating changes, follow the patterns in + `spec/models/page_spec.rb` to assert version transitions and item validation. + +Use this reference when automating editor customisations, generating documentation, or writing migrations—each bullet +maps to concrete files in the `content/` engine, making it safe for agents to reason about entry points. diff --git a/docs/feedback.md b/docs/feedback.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/koi-user-guide.md b/docs/koi-user-guide.md index af70a6d4..2f039301 100644 --- a/docs/koi-user-guide.md +++ b/docs/koi-user-guide.md @@ -7,6 +7,9 @@ This guide explains how to use Koi to build consistent administration areas in c - **Setting up a project:** see [`koi-setup-guide.md`](./koi-setup-guide.md) for end-to-end bootstrap steps (template workflow and retrofit path). - **Managing admin users:** see [`user-management.md`](./user-management.md) for provisioning, authentication options, and day-to-day maintenance tasks. - **Building admin modules:** see [`admin-module-workflow.md`](./admin-module-workflow.md) for the full generator-driven process and advanced tips on ordering, archiving, and regeneration. +- **Working with Koi Content:** see [`content.md`](./content.md) for wiring the editor, preview workflow, and frontend rendering helpers. +- **Archiving modules:** see [`archiving.md`](./archiving.md) for soft-delete conventions, UI wiring, and testing expectations. +- **Serving CMS pages at root:** see [`root-level-page-routing.md`](./root-level-page-routing.md) for exposing `Page` slugs at `/slug` while keeping previews behind admin sessions. ## What Koi Provides (and What It Does Not) diff --git a/docs/root-level-page-routing.md b/docs/root-level-page-routing.md new file mode 100644 index 00000000..80045a36 --- /dev/null +++ b/docs/root-level-page-routing.md @@ -0,0 +1,131 @@ +# Root-Level Page Routing + +## Overview +Use this pattern when editors manage marketing or CMS pages in Koi but expect URLs like `/about-us` or `/contact` instead of `/pages/about-us`. The pattern combines a route constraint, a resolver, and controller helpers so: + +- the router keeps page slugs at the top level without colliding with other resources; +- `PagesController::Constraints` decides whether to serve the request (published pages for the public, drafts for admins via preview); +- the matched page is cached on the Rack request (`katalyst.matched.page`) so subsequent lookups (including SEO helpers) reuse it; +- URL helpers such as `page_path(page)` resolve correctly because of the custom `resolve("Page")` declaration. + +## Common Use Cases + +- Marketing sites where `Page` records powered by Katalyst Content should render at the root path. +- Landing pages that need draft previews behind admin authentication without leaking unpublished content. +- Projects with a single CMS-backed module that owns the remaining slug space (e.g. `Pages`, `Articles` when everything else lives under `/admin` or namespaced routes). + +Although the implementation is reusable, confirm there is only one module expected to match arbitrary root slugs; multiple modules using the same technique would compete for every unknown path. + +## Routing Setup + +Add the constraint block near the end of your public routes (after any explicit top-level paths that must win): + +```ruby +constraints PagesController::Constraints do + resources :pages, path: "", only: [:show], param: :slug, constraints: { format: :html } do + get :preview, on: :member + end +end + +resolve("Page") { |page, options = {}| [:page, { slug: page.slug, **options }] } +``` + +Key details: + +- `path: ""` removes the `/pages` prefix while still relying on `resources` wiring and helpers. +- The collection is read-only; only `show` and `preview` are exposed to avoid accidental mass routing. +- `constraints: { format: :html }` keeps JSON, RSS, and other formats available for other controllers. +- `resolve("Page")` ensures `polymorphic_path(page)` and friends generate the friendly slug rather than `/pages/:id`. + +## Controller Responsibilities + +The public controller handles lookups, previews, and SEO helpers while delegating gatekeeping to the inner constraint: + +```ruby +class PagesController < ApplicationController + helper Katalyst::Content::FrontendHelper + + def show + render locals: { page:, version: page.published_version } + end + + def preview + return redirect_to action: :show if page.state == "published" + + render :show, locals: { page:, version: page.draft_version } + end + + def seo_metadatum + page.seo_metadatum + end + + private + + def page + if request.has_header?("katalyst.matched.page") + request.get_header("katalyst.matched.page") + else + Page.find_by!(slug: params[:slug]) + end + end + + class Constraints + attr_reader :request + + def self.matches?(request) = new(request).match? + + def initialize(request) + @request = request + end + + def match? + request.set_header("katalyst.matched.page", page) + return false if page.blank? + + case action + when "show" then page.published? + when "preview" then admin? + else false + end + end + + def action = request.params[:action] + + def admin? = request.session[:admin_user_id].present? + + def page + return unless request.params[:slug] && request.get? + @page ||= Page.find_by(slug: request.params[:slug]) + end + end +end +``` + +Highlights: + +- The constraint only runs for GET requests with a `slug` param, letting other verbs fall through. +- Matched pages are cached on the request, so the controller, SEO helpers, and downstream services (e.g. layout components) share one lookup. +- `match?` returns `false` when no page is found or when a non-admin requests a preview, letting the router continue to the next route. +- `admin?` piggybacks on the `admin_user_id` session that Koi already sets when an admin is signed in. +- `preview` redirects to `show` when the page is already published, ensuring canonical URLs. +- The `seo_metadatum` pass-through demonstrates how additional controller actions can reuse the cached page without hitting the database again. + +## Implementation Checklist + +- **Confirm requirements** – when a feature request mentions a “Pages” model, ask whether top-level slugs are expected. Default to this pattern when the answer is yes. +- **Slug uniqueness** – ensure the `pages` table validates and indexes `slug` uniqueness; otherwise collisions produce confusing fallbacks. +- **Route ordering** – keep explicit static routes (`get "/health"`) above the constraint block. Anything defined below may never run because the constraint claims the slug first. +- **Preview access** – verify that admin authentication is in place (via `Koi.config.authenticate_local_admins` or real login) before relying on previews. +- **Caching hooks** – if you add middleware or helpers that assume `request.page`, use the header set in `match?` to avoid duplicate queries. +- **Specs** – cover `GET /:slug` for published pages, `GET /:slug/preview` for admins, and missing slugs falling through to a 404. + +## Extending Beyond Pages + +This approach works for any single module that owns the remaining root paths—swap `Page`/`PagesController` for your model/controller pair. Before doing so: + +- confirm no other module needs arbitrary root slugs (otherwise introduce a namespace or prefix to avoid ambiguity); +- adjust the constraint’s `admin?` and visibility checks to match the module’s publication workflow; +- update `resolve("ModelName")` accordingly so polymorphic helpers stay correct. + +If you genuinely need multiple modules sharing the root namespace, consider a dispatcher that inspects the slug format or a database-backed router instead of duplicating this constraint class. +