diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb index 27621ced..2c403a2a 100644 --- a/app/views/admin/sessions/new.html.erb +++ b/app/views/admin/sessions/new.html.erb @@ -24,7 +24,7 @@ <%= form.hidden_field :response, data: { webauthn_authentication_target: "response" } %> <%= hidden_field_tag(:redirect, redirect) %>
- <%= form.admin_save "Next" %> + <%= form.button("Next", type: :submit, class: "button") %> <%= form.button(type: :button, class: "button button--secondary", data: { action: "webauthn-authentication#authenticate" }) do %>   <% end %> diff --git a/app/views/admin/sessions/otp.html.erb b/app/views/admin/sessions/otp.html.erb index 09615e46..39d3f1df 100644 --- a/app/views/admin/sessions/otp.html.erb +++ b/app/views/admin/sessions/otp.html.erb @@ -4,5 +4,5 @@ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %> <%= form.govuk_text_field :token, autofocus: true, autocomplete: "off" %> <%= hidden_field_tag(:redirect, params[:redirect]) %> - <%= form.admin_save "Next" %> + <%= form.button("Next", type: :submit, class: "button") %> <% end %> diff --git a/app/views/admin/sessions/password.html.erb b/app/views/admin/sessions/password.html.erb index 54705669..ba8e69eb 100644 --- a/app/views/admin/sessions/password.html.erb +++ b/app/views/admin/sessions/password.html.erb @@ -5,7 +5,7 @@ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %> <%= form.govuk_password_field :password, autofocus: true, autocomplete: "off" %> <%= hidden_field_tag(:redirect, params[:redirect]) %> - <%= form.admin_save "Next" %> + <%= form.button("Next", type: :submit, class: "button") %> <%# init govuk js to provide the show/hide button %> <%= govuk_formbuilder_init %> diff --git a/app/views/admin/tokens/show.html.erb b/app/views/admin/tokens/show.html.erb index 593e5539..4f8dd557 100644 --- a/app/views/admin/tokens/show.html.erb +++ b/app/views/admin/tokens/show.html.erb @@ -7,6 +7,6 @@ <%= row.text :email %> <% end %>
- <%= form.admin_save "Sign in" %> + <%= form.button("Sign in", type: :submit, class: "button") %>
<% end %> diff --git a/docs/admin-module-workflow.md b/docs/admin-module-workflow.md index 98d07d5a..20bba73a 100644 --- a/docs/admin-module-workflow.md +++ b/docs/admin-module-workflow.md @@ -79,7 +79,9 @@ Picking a stable attribute (title, name, slug) keeps UI labels consistent across - 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`). +- Keep form actions focused on saving form content (plain submit buttons in form sections). +- Put non-form actions (archive/delete/view/preview) in page header actions via `actions_list`, `link_to_delete`, and `link_to_archive_or_delete`. +- `Koi::FormBuilder` provides 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`). @@ -98,7 +100,7 @@ Picking a stable attribute (title, name, slug) keeps UI labels consistent across ### 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`). +- When the module is archivable, selection columns and bulk archive/restore actions should be available on active/archived indexes (`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 @@ -122,7 +124,7 @@ Each controller defines an inner `Collection` class extending `Admin::Collection 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`). +- Index/archived views include bulk archive/restore via selection controls plus a link between active and archived lists (`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. @@ -138,7 +140,7 @@ Every admin module is added to `Koi::Menu.modules` with a label derived from the ## 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. +- **Override form layouts** using ViewComponents or partials if you need multi-column layouts—keep form controls save-focused and move non-form actions to header actions. - **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. @@ -166,6 +168,7 @@ Every admin module is added to `Koi::Menu.modules` with a label derived from the - 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. +- In request specs, prefer combined redirect assertions (`have_http_status(:see_other).and(redirect_to(...))`). - 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. diff --git a/docs/koi-5-admin-upgrade-guide.md b/docs/koi-5-admin-upgrade-guide.md new file mode 100644 index 00000000..a7e41ec9 --- /dev/null +++ b/docs/koi-5-admin-upgrade-guide.md @@ -0,0 +1,133 @@ +# Koi 5 Admin Upgrade Guide + +## Purpose + +Use this guide to upgrade legacy admin modules to Koi 5 while preserving product behavior and reducing drift. + +## Preflight checklist + +- Ensure the app and admin shell both load (`https://localhost`, `https://localhost/admin`). +- Install required engine migrations and migrate before feature work. +- Confirm admin CSS is pure CSS (Koi 5), not Sass-based admin entrypoints. +- Create a baseline checkpoint commit before module-by-module uplift. + +## Module archetype: decide first + +Pick lifecycle semantics before touching controller/view code. + +- `Simple CRUD` (eg project types, industries): no lifecycle state; optional ordering. +- `Archivable` (eg staff, clients): boolean lifecycle with `archive/restore`; optional archived index. +- `Content stateful` (eg articles, pages, case studies): `publish/unpublish` via content state (`draft_version_id` / `published_version_id`). + +Do not mix archetypes inside one module unless there is a strong product reason. + +## Canonical module workflow + +1. Regenerate with `bin/rails generate koi:admin --force`. +2. Fix routes first (dedupe generator inserts, correct collection/member actions, confirm helpers with `rails routes`). +3. Align controller shape to Koi 5: + - locals-based rendering + - `params.expect` + - explicit collection actions + - `:see_other` redirect semantics +4. Move defaults/business invariants to model + migration (not controller setup). +5. Rework views to Koi 5 patterns (header/actions, concise table rows, summary table where useful). +6. Update request specs to match intended behavior. +7. Run module specs, admin request specs, then full suite. +8. Checkpoint commit. + +## Strong params: `expect` rules + +`expect` is stricter than legacy `require(...).permit(...)` for repeated nested structures. + +- Scalar arrays: `author_ids: []` +- Repeated nested hashes: `items_attributes: [[:id, :index, :depth]]` +- Single nested hash: `seo_metadatum_attributes: %i[id title description keywords _destroy]` + +Do not translate repeated nested structures from `permit` without checking shape. + +## View conventions + +- Keep index pages concise; only high-signal fields for triage/navigation. +- Avoid images on index/archived unless operationally required. +- State-scoped pages should not show the state column. +- Prefer explicit path helpers and avoid polymorphic path indirection where readability suffers. +- Use plain submit buttons; avoid `form.admin_save` / `form.admin_delete` in new Koi 5 work. +- Keep `form_with` in `new`/`edit` and use shared fields partials (`_metadata_fields`) for common inputs. +- Keep custom sections (eg SEO) explicit in `edit`, not hidden behind option locals. +- In ERB, use parentheses for helper calls with arguments, especially helper calls that also take a block. + +### Header action helpers + +- Use header action links for lifecycle/destructive actions rather than form action buttons. +- `link_to_delete(record)` always issues a delete request and confirms by default. +- `link_to_archive_or_delete(record)` archives active records and only shows delete when already archived. +- For non-standard routes, pass an explicit `url:` to avoid polymorphic mismatch. + +### Archivable table UX + +- For Koi 5 archivable modules, include selection checkboxes on active and archived indexes. +- Provide bulk `archive` and bulk `restore` actions via table selection controls. + +## Ordering rules + +- Keep ordering on the active/current index only. +- Do not paginate orderable index pages. +- Paginate non-orderable archived pages by default. +- Keep ordering concerns separate from lifecycle actions. + +## Content module strategy + +Use a phased approach: + +- `Phase A (top/tail)`: index/new/edit first. +- `Phase B (editor)`: show/update content editor workflow. + +For show/update in content modules: + +- Build editor with `Katalyst::Content::EditorComponent.new(container: record)`. +- Preserve commit semantics (`publish`, `save`, `revert`). +- Invalid update from show should return turbo editor errors with `:unprocessable_content`. + +## Lifecycle migration safety + +When converting legacy lifecycle models (eg `active` -> content state): + +- Migrate data first to preserve intent (eg inactive -> unpublished). +- Then remove legacy columns/scopes/UI paths. +- Update frontend selectors/scopes so hidden records do not reappear. + +## Admin CSS + content icons + +- Koi 5 admin stylesheet is CSS (`@import url("koi/index.css")`). +- Content 3 icon overrides use `[data-icon="..."]`; old `value` / `data-item-type` selectors no longer work. +- Custom content icons should use `viewBox="0 0 16 16"` and `stroke-width="2"`. + +## Testing guidance + +- Use tight request-spec loops while upgrading each module. +- Keep tests focused on app behavior, not Katalyst Tables internals. +- Avoid query-specific table tests like "finds needle" / "hides chaff". +- Use combined redirect assertions: `have_http_status(:see_other).and(redirect_to(...))`. + +## Done criteria for one module + +- Routes/helpers are correct and non-duplicated. +- Lifecycle behavior matches selected archetype. +- Index/show/edit UX follows Koi 5 conventions. +- Strong params `expect` shape is correct (especially repeated nested attributes). +- Module request specs pass. +- Admin request suite passes. +- Full suite passes (excluding known pending tests). + +## References + +- Koi controller patterns: `koi/app/controllers/admin/admin_users_controller.rb`, `koi/app/controllers/admin/url_rewrites_controller.rb` +- Koi views: `koi/app/views/admin/admin_users/index.html.erb`, `koi/app/views/admin/admin_users/archived.html.erb` +- Real-world archive/order pattern: `frg/app/controllers/admin/merchandise_controller.rb`, `frg/app/views/admin/merchandise/index.html.erb` +- Content editor contract: `koi/docs/skills/content-admin.md` +- Local upgrade examples in this repo: + - `kat/app/controllers/admin/staff_controller.rb` + - `kat/app/controllers/admin/articles_controller.rb` + - `kat/app/controllers/admin/pages_controller.rb` + - `kat/app/controllers/admin/case_studies_controller.rb` diff --git a/docs/koi-user-guide.md b/docs/koi-user-guide.md index 2f039301..9a201943 100644 --- a/docs/koi-user-guide.md +++ b/docs/koi-user-guide.md @@ -174,15 +174,17 @@ In views, use the provided helpers: - `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`. +- For archivable resources, include selection controls on active and archived tables so users can bulk archive and bulk restore. ### Forms -`Koi::FormBuilder` combines `GOVUKDesignSystemFormBuilder` with helper shortcuts (`lib/koi/form_builder.rb`). Key helpers include: +`Koi::FormBuilder` combines `GOVUKDesignSystemFormBuilder` with helper shortcuts (`lib/koi/form_builder.rb`). -- `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`. +- In module forms, keep submit controls focused on saving form content (plain submit buttons). +- Put non-form lifecycle actions in page header actions (`actions_list`) with `link_to_delete(record)` or `link_to_archive_or_delete(record)`. 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`). diff --git a/docs/skills/content-admin.md b/docs/skills/content-admin.md new file mode 100644 index 00000000..84018c20 --- /dev/null +++ b/docs/skills/content-admin.md @@ -0,0 +1,402 @@ +# Skill: Content Admin (Existing Model) + +Use this guide when adding Katalyst Content editing to an already-existing admin resource in a Koi app. + +This is the canonical v3 pattern the planned content-admin generator should produce. + +## Scope + +- Existing model only (hard requirement). +- Targets `katalyst-content` v3 behavior. +- Focuses on admin CRUD + editor wiring, not frontend page rendering. + +## Generator Contract Checklist + +Use this as the short, testable contract for generator templates and generator specs. + +1. **Controller helper and editor wiring** + - Generated controller includes `helper Katalyst::Content::EditorHelper`. + - `show` builds `Katalyst::Content::EditorComponent.new(container: resource)`. + +2. **Strong params include content tree** + - Generated params include `items_attributes: [%i[id index depth]]`. + +3. **Create builds initial draft** + - Successful `create` calls `build_draft_version` and persists it. + +4. **Update supports content workflow commits** + - Handles `commit` values: `publish`, `save`, `revert`. + +5. **Update invalid from show renders turbo errors** + - Invalid update path can render `editor.errors` with `:unprocessable_content` for turbo stream requests. + +6. **Bulk publish/unpublish actions exist** + - Generated controller includes collection `publish` and `unpublish` actions. + - Generated routes include matching collection endpoints. + +7. **Views include editor shell on show** + - `show.html.erb` renders `editor.status_bar` and `render editor`. + +8. **Expected template set exists** + - Generated views include: `index`, `show`, `new`, `edit`, and fields partial. + +9. **Force overwrite supported for upgrades** + - Generator supports force-overwrite workflows for upgrade diffing. + +10. **Existing model only guard** + - Generator does not create model/migration files. + +## Style expectations + +- Use parentheses for method calls with multiple arguments. +- In views, use parentheses for helper calls that take arguments, including calls that take a block. +- In DSL-style Ruby code (for example request-spec `get/post/patch` flows), omitting parentheses is acceptable when it reads more clearly. +- In request specs, prefer combined redirect assertions: + - `expect(response).to have_http_status(:see_other).and(redirect_to(...))` + +## Prerequisites + +Before wiring actions/views, confirm all of the following. + +1. **Gem version** + - App is on `katalyst-content` v3. + - Verify in `Gemfile.lock`. + +2. **Model setup** + - Model includes `Katalyst::Content::Container`. + - Nested `Version` model includes `Katalyst::Content::Version`. + - Model has `draft_version_id` and `published_version_id` references. + + Example: + + ```ruby + class Page < ApplicationRecord + include Katalyst::Content::Container + + class Version < ApplicationRecord + include Katalyst::Content::Version + end + end + ``` + +3. **Admin base controller** + - Resource controller inherits from your admin base (typically `Admin::ApplicationController`). + - Admin base includes Koi controller concerns. + +4. **Content editor mount/config** + - Content engine is mounted under admin scope (Koi default: `/admin/content`). + - Koi content initializer behavior is active (`base_controller`, errors component). + +5. **Resource routing shape** + - Decide whether resource lookup is by `id` or `slug`. + - Keep this consistent in controller, routes, and link helpers. + +6. **Display identity** + - Model implements meaningful `to_s` (for headers, breadcrumbs, links). + +## Expected Actions + +The resource controller should expose these actions and behavior. + +### `index` + +- Render standard admin collection/list view. +- Include bulk publish/unpublish actions when supported by routes. + +### `show` + +- Build editor with `Katalyst::Content::EditorComponent.new(container: record)`. +- Render locals with both record and editor. + +### `new` / `edit` + +- Render metadata form only (title/slug/etc), not the content tree. +- For project-specific metadata concerns (for example SEO), build nested data before rendering edit. + +### `create` + +- Build and save resource from strong params. +- On success, create the initial draft: + - `record.build_draft_version` + - `record.save!` +- Redirect to admin show. +- On failure, render `:new` with `:unprocessable_content`. + +### `update` + +- Assign params first (`record.attributes = ...`). +- If invalid: + - If request came from `show`, return turbo-frame errors from `editor.errors`. + - If request came from `edit`, render `:edit` with `:unprocessable_content`. +- If valid, branch by submit intent: + - `publish`: `save!` then `publish!` + - `save`: `save!` + - `revert`: `revert!` +- Redirect back to show (or back to previous page) using existing app convention. + +### `publish` / `unpublish` + +- Collection actions updating selected ids: + - `publish!` for each selected record. + - `unpublish!` for each selected record. +- Redirect to index with `:see_other`. + +### `destroy` + +- If currently published: unpublish and redirect to edit. +- Else: destroy and redirect to index. + +## Controller Example + +Adapt this to your resource naming and lookup strategy: + +```ruby +# app/controllers/admin/pages_controller.rb +module Admin + class PagesController < ApplicationController + helper Katalyst::Content::EditorHelper + + before_action :set_page, only: %i[show edit update destroy] + + def show + editor = Katalyst::Content::EditorComponent.new(container: page) + render(locals: { page:, editor: }) + end + + def create + @page = Page.new(page_params) + + if page.save + page.build_draft_version + page.save! + redirect_to(admin_page_path(page), status: :see_other) + else + render(:new, locals: { page: }, status: :unprocessable_content) + end + end + + def update + page.attributes = page_params + + unless page.valid? + case previous_action + when "show" + editor = Katalyst::Content::EditorComponent.new(container: page) + return respond_to do |format| + format.turbo_stream { render(editor.errors, status: :unprocessable_content) } + end + when "edit" + return render(:edit, 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 + + if previous_action == "edit" + redirect_to(admin_page_path(page), status: :see_other) + else + redirect_back_or_to(admin_page_path(page), status: :see_other) + end + end + + def publish + Page.where(id: params.expect(id: [])).each(&:publish!) + redirect_back_or_to(admin_pages_path, status: :see_other) + end + + def unpublish + Page.where(id: params.expect(id: [])).each(&:unpublish!) + redirect_back_or_to(admin_pages_path, status: :see_other) + end + + def destroy + if page.published? + page.unpublish! + redirect_to(edit_admin_page_path(page), status: :see_other) + else + page.destroy! + redirect_to(admin_pages_path, status: :see_other) + end + end + + private + + attr_reader :page + + def set_page + @page = Page.find_by!(slug: params.expect(:slug)) + end + + def previous_action + previous = request.referer&.split("/")&.last + %w[show edit].include?(previous) ? previous : "show" + end + + def page_params + return {} if params[:page].blank? + + params.expect(page: [:title, :slug, { items_attributes: [%i[id index depth]] }]) + end + end +end +``` + +## Expected Views + +At minimum, generate and maintain these templates: + +- `index.html.erb` +- `show.html.erb` +- `new.html.erb` +- `edit.html.erb` +- `_fields.html.erb` (or `_form_fields.html.erb`, project convention) + +### `show.html.erb` + +`show` is where content editing lives. It should render the status bar and editor shell. + +```erb +<%# locals: (page:, editor:) %> + +<%= render(editor.status_bar) %> +<%= render(editor) do |editor_component| %> + <% editor_component.with_new_items do |component| %> +

Content

+ + +

Layout

+ + <% end %> +<% end %> +``` + +Notes: + +- The item palette is intentionally project-specific. +- Keep group headings and ordering explicit so upgrades are easy to diff. + +### `new` / `edit` + +- Render metadata fields only. +- Keep the form actions focused on saving form content (plain submit button). +- Place non-form actions (for example view/preview/delete/unpublish) in the page header actions section. +- Keep content tree editing in `show`. + +### Header action link helpers + +Use Koi header helpers for destructive/lifecycle links in page-header actions. + +- `link_to_delete(record)` + - Renders a delete link for persisted records. + - Uses `turbo_method: :delete` with a confirmation prompt by default. + - Override URL when polymorphic admin paths are not correct for the module. + +- `link_to_archive_or_delete(record)` + - For archivable models only (`record` must respond to `archived?`). + - If record is active, renders an **Archive** action. + - If record is already archived, renders a **Delete** action with confirmation. + +Example: + +```erb +<% content_for :header do %> + <%= actions_list do %> +
  • <%= link_to("View", page_path(page), target: "_blank") %>
  • +
  • <%= link_to_archive_or_delete(page) %>
  • + <% end %> +<% end %> +``` + +### `index` + +- Include search/query + table list. +- Include bulk publish/unpublish buttons when routes/actions exist. +- Include state column where available. +- For archivable modules, include row selection with bulk archive/restore actions. + +## Tests (First Draft) + +Cover behavior with request specs first. These tests define the expected generator contract. + +### Required request spec coverage + +1. Auth protection for admin endpoints. +2. `GET show` renders editor UI. +3. `POST create` creates record and initial draft version. +4. `PATCH update` invalid from show returns turbo error frame (`:unprocessable_content`). +5. `PATCH update` with `commit=save` persists changes. +6. `PATCH update` with `commit=publish` publishes. +7. `PATCH update` with `commit=revert` reverts draft to published. +8. `PUT publish` collection action publishes selected records. +9. `PUT unpublish` collection action unpublishes selected records. +10. `DELETE destroy` unpublishes when published, destroys when unpublished. + +### Example spec snippets + +```ruby +describe "POST /admin/pages" do + it "creates an initial draft version" do + post admin_pages_path, params: { page: { title: "About", slug: "about" } } + + page = Page.find_by!(slug: "about") + expect(response).to have_http_status(:see_other).and(redirect_to(admin_page_path(page))) + expect(page.draft_version).to be_present + end +end +``` + +```ruby +describe "PATCH /admin/pages/:slug" do + it "returns inline editor errors for turbo requests" do + page = create(:page, :with_content_draft, slug: "about") + + patch admin_page_path(page), + params: { page: { title: "" }, commit: "save" }, + headers: { "ACCEPT" => "text/vnd.turbo-stream.html" } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("_errors") + end +end +``` + +```ruby +describe "PATCH /admin/pages/:slug" do + it "publishes when commit is publish" do + page = create(:page, :with_content_draft) + + patch admin_page_path(page), params: { page: { title: page.title }, commit: "publish" } + + expect(response).to have_http_status(:see_other).and(redirect_to(admin_page_path(page))) + expect(page.reload).to be_published + end +end +``` + +## Force Regeneration Workflow + +Force-overwrite is expected for upgrade diffs. + +1. Re-run generator with force enabled. +2. Review generated diff against current implementation. +3. Keep canonical controller/view contracts from this skill. +4. Re-apply project-specific customizations (item palette, custom fields, headers). +5. Run request specs and a manual content editor smoke test. + +This keeps upgrades intentional while preserving local behavior where it is deliberately custom.