Skip to content

Conversation

@sahitya-chandra
Copy link

@sahitya-chandra sahitya-chandra commented Jan 30, 2026

Issue: #3030
Closes #3030
Parent issue: #3028
previous pr: #3096 and #3166 and #3202

Description

Problem

The Edit Product page (/products/:id/edit) used a single React on Rails server-rendered flow with one monolithic LinksController#edit and LinksController#update. All tabs (Product, Content, Receipt, Share) were handled via params[:tab], causing full page reloads when switching tabs and sending unnecessary data for each tab.

Solution

Migrated the Edit Product page to Inertia with per-tab server endpoints, matching the pattern used in merged PRs (Bundles #3173).

Approach:

  1. Dedicated controller per tab – Each tab has its own GET (edit) and PATCH/PUT (update) endpoint with resource-style URLs:

    • Product/products/:id/product/editProducts::Edit::ProductController
    • Content/products/:id/content/editProducts::Edit::ContentController
    • Receipt/products/:id/receipt/editProducts::Edit::ReceiptController
    • Share/products/:id/share/editProducts::Edit::ShareController
    • Backward-compatibility redirects for old URLs (e.g. /products/:id/edit/products/:id/product/edit).
  2. Decoupled props per tab – Each edit page receives only the props it needs. Products::Edit::BasePresenter provides:

    • layout_props – shared top-level props (id, unique_permalink, seller, currency_type, taxonomies, etc.) with no product.
    • Tab-specific product props: product_tab_product_props (full product for Product tab), content_tab_product_props (name, files, rich_content, variants, etc.), receipt_tab_product_props (name, custom_receipt_text, custom_view_content_button_text, etc.), share_tab_product_props (name, tags, taxonomy_id, display_product_reviews, is_adult, custom_domain, etc.). Each tab presenter merges layout_props with its tab’s product hash so Content/Receipt/Share do not receive pricing, refund, or other unrelated fields.
  3. Inertia pages – One page component per tab under app/javascript/pages/Products/Edit/ (Product.tsx, Content.tsx, Receipt.tsx, Share.tsx), with a shared ProductEditLayout.tsx using Inertia’s useForm() for save.

  4. Dual Inertia + JSON – Product edit controllers support both:

    • Inertia: form.patch() from the "Save changes" button → redirect + flash.
    • JSON: Non-Inertia callers → { success: true }.

    Publish/unpublish in LinksController support both: Inertia — "Save and continue" / "Unpublish" call save() (form.patch) then router.post(), with server-side flash ("Published!" / "Unpublished!"); JSON — for other callers.

  5. Cleanup – Removed old React on Rails entry: packs/product_edit.ts, views/links/edit.html.erb, ProductEditPage.tsx server component, and the monolithic edit/update actions from LinksController.

Key files:

  • app/controllers/products/edit/base_controller.rb – Base for all tab controllers, layout "inertia".
  • app/controllers/products/edit/*_controller.rb – Tab-specific edit/update with request.inertia? for dual response.
  • app/presenters/products/edit/base_presenter.rblayout_props and tab-specific product prop methods; tab presenters compose props per tab.
  • app/presenters/products/edit/*_tab_presenter.rb – Each returns layout_props.merge(product: <tab>_tab_product_props).
  • app/javascript/layouts/ProductEditLayout.tsx – Inertia layout with useForm(), form.patch() for save.
  • app/javascript/pages/Products/Edit/*.tsx – Per-tab Inertia pages.
  • app/javascript/components/ProductEdit/Layout.tsx – Tab chrome, Save/Publish/Unpublish; uses Inertia Link/router.visit and router.post for publish/unpublish.
  • app/policies/link_policy.rb – Permitted attributes split into *_tab_permitted_attributes per tab.

Other changes:

  • config/initializers/react_on_rails.rbcontroller.params = ActionController::Parameters.new(params) so React on Rails SSR (e.g. checkout) works with strong parameters. Pre-existing bug fix, not specific to this migration.
  • app/javascript/packs/inertia.js – CSRF token set on requestDefaults.headers for non-Inertia AJAX (image uploads, tag suggestions) from Product Edit components.

Before / After

  • Before: One route, one controller, one React on Rails app; tab switch = full reload; all tab data sent every time.
Screencast.from.2026-01-28.19-04-58.webm
  • After: One URL per tab, one controller and presenter per tab; tab switch = Inertia SPA navigation; each tab gets only its data.
Screencast.from.2026-01-30.22-02-04.webm

Implementation notes

  • Routes: Resource-style routes under resources :products with per-tab resource for edit/update (edit_product_product_path, etc.). No explicit path: "product"; default path is used. Backward-compatibility redirects for legacy URLs.
  • Flash & redirects: Success/error redirects pass notice:/alert: in the redirect and use status: :see_other (303). No separate flash assignment before redirect.
  • Unpublish: When the user clicks Unpublish on Product or Content tab, the action short-circuits and only unpublishes (no full tab update) so the request works when the frontend sends only { unpublish: true } and to avoid membership tier validation. Share tab runs update in transaction then unpublish; Receipt tab uses LinksController#unpublish POST.
  • Error handling: Method-level rescue at the end of each update action (no begin/rescue). All error redirects use status: :see_other.
  • Product::VariantsUpdaterService: price_difference_cents can be nil when not sent (e.g. non-coffee variant) or blank; .to_i coerces string params from forms. Comment added in the service.

Test results

  • Product edit controllers: 33 examples in spec/controllers/products/edit/ (Product, Content, Receipt, Share; GET edit + PATCH update, authorize/collaborator, tab-specific behavior).
  • Product edit presenters: 6 examples in spec/presenters/products/edit/ (BasePresenter layout_props/product_minimal_props; ProductTabPresenter, ContentTabPresenter, ReceiptTabPresenter, ShareTabPresenter each return layout props + tab-specific product props only).
  • Publish/unpublish: Covered by spec/controllers/links_controller_spec.rb (POST publish at line 51, POST unpublish at line 162). Total: 33 controller + 6 presenter + 15 publish/unpublish = 54 examples for this migration.

Run:

bundle exec rspec spec/controllers/products/edit/ spec/presenters/products/edit/ spec/controllers/links_controller_spec.rb:51 spec/controllers/links_controller_spec.rb:162 --format documentation
Screenshot from 2026-01-30 22-00-23

Checklist

AI Disclosure

  • used cursor with opus-4.5 to plan the solution and refactoring code

  • AI was also used to:

  1. Generate the initial boilerplate for the 4 new controllers and presenters.
  2. Refactor LinkPolicy to split permitted attributes.
  3. Write the rspec test suite for the new controllers.
  4. Assist in migrating the saveProduct frontend logic to use dynamic Inertia endpoints.

- Extract shared permitted attributes in LinkPolicy for better maintainability.
- Update routes for product editing to include separate paths for each tab (product, content, receipt, share).
- Enhance controller specs to support both Inertia and JSON API requests for product updates across all tabs.
- Convert publish/unpublish to use Inertia router.post() instead of
  legacy AJAX saveProduct/setProductPublished utilities
- Remove JSON rendering from product edit controllers (now Inertia-only)
- Update save() in ProductEditLayout to return Promise for proper
  sequencing with publish
- Add Inertia support to links_controller publish/unpublish actions
  with flash messages and redirects
- Remove JSON API tests from controller specs (now 9 tests)
- Clean up unused imports in Layout.tsx

This addresses reviewer feedback about using server-side flash messages
instead of client-side showAlert for success/error handling.
    - Add tests for publish with Inertia request (redirect + flash notice)
    - Add tests for publish error handling with Inertia (flash error)
    - Add tests for unpublish with Inertia request (redirect + flash notice)
    - Add basic unpublish JSON test for completeness
…ontrollers

- Implemented shared examples for authorization checks in Products::Edit::ContentController, Products::Edit::ProductController, Products::Edit::ReceiptController, and Products::Edit::ShareController.
- Added tests to ensure collaborators can access update actions in the respective controllers.
- Enhanced existing tests to cover additional scenarios, including handling of product attributes and settings.
Resolved conflicts:
- app/controllers/links_controller.rb: keep branch (no edit/update; handled by Products::Edit::*)
- app/policies/link_policy.rb: add update_purchases_content? from main, keep shared_tab_permitted_attributes
- spec/controllers/links_controller_spec.rb: keep branch (no GET edit / PUT update specs)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each edit tab now gets only the product props it needs: Product tab gets full product; Content gets name, files, rich_content, variants, etc.; Receipt gets receipt/custom text fields; Share gets tags, taxonomy_id, display_product_reviews, is_adult, custom_domain. Layout still gets shared top-level props via layout_props (no product). This avoids sending pricing/refund/share data to tabs that don’t use it.

Comment on lines +283 to +285
resource :product, only: [:edit, :update], controller: "edit/product"
resource :content, only: [:edit, :update], controller: "edit/content"
resource :receipt, only: [:edit, :update], controller: "edit/receipt"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped path: "product" from the product resource; the default path for a singular :product resource is already "product", so behavior is unchanged and the config is simpler.

# TODO: :product_edit_react cleanup
if option[:price_difference_cents].present?
option[:price] = option[:price_difference_cents]
option[:price] = option[:price_difference_cents].to_i
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

price_difference_cents can be nil when not sent (e.g. non-coffee variant) or blank, and that .to_i is used to coerce string params from forms. The existing .present? guard and .to_i logic are unchanged.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added specs to ensure each tab presenter returns layout props plus only that tab’s product keys (e.g. Content/Receipt/Share do not include price_cents, refund_policy). BasePresenter specs cover layout_props (no product) and product_minimal_props (name, is_published, files, native_type).

Copy link
Author

@sahitya-chandra sahitya-chandra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self review

@neetogit-bot neetogit-bot bot assigned EmCousin and Pradumn27 and unassigned EmCousin Jan 30, 2026
@sahitya-chandra
Copy link
Author

@EmCousin sir,

I have incorporated your review comments in #3202. Could you take another look when you have time? Thanks.

Cc @Pradumn27

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate Edit Product page to Inertia

3 participants