Redesign Import Rules as an interactive workbench#200
Conversation
Replace the plain Import Rules table + separate form/preview pages with a single split-pane workbench, recreating a Claude Design prototype in server-rendered Hotwire (Turbo + Stimulus). No schema changes. - Split-pane layout: a searchable/filterable rule list on the left and an inline editor on the right loaded into a Turbo Frame (no page jumps). Saving keeps the rule open and highlights its row; the editor sticks and scrolls so it stays usable with a long rule list. - Live pattern testing: as you type, the editor shows the matching ledger transactions (highlighted), preloaded server-side on open with a delayed loading indicator. Already-excluded matches are listed and badged. Backed by a new ImportRule::MatchPreview service that mirrors the rule-matching SQL transaction-side. - Drag-to-reorder priority (now positional; the numeric field is gone) via a reorder endpoint + native HTML5 drag-and-drop. - Per-rule Preview and "Preview & apply all" as native <dialog> modals with the prototype's fade/rise transitions. Per-rule Preview reflects unsaved edits, and its "Save & apply" persists the rule and applies it retroactively in one step. - ImportRule::RetroactiveApply (the modal/apply) and the live preview share a new ImportRule::TransactionScope module, so the editor preview and the modal always agree on what would change. Styles live in a namespaced app/assets/stylesheets/import_rules.css. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 09c40898bf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| current_user.import_rules.build( | ||
| match_pattern: params[:pattern], | ||
| match_type: match_type, | ||
| account_id: (exclude ? nil : params[:account_id].presence), |
There was a problem hiding this comment.
Scope the draft account lookup to the current user
When an authenticated user requests the new collection preview endpoint with another user's enumerable account_id, this unvalidated draft resolves that global Account association and the preview renders its name and kind via _change_row.html.erb. The normal save validation prevents assigning it, but preview does not run validation; resolve the account through current_user.accounts as MatchPreview already does.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in bdcffff: build_draft_rule now resolves the account via current_user.accounts.find_by(...), so a foreign account_id resolves to nil and can't surface in the un-validated draft preview/apply. Added a controller test ("draft preview ignores another user's account_id").
| case @match_type | ||
| when "exact" then "LOWER(transactions.description) = :pattern" | ||
| else "LOWER(transactions.description) LIKE :pattern ESCAPE '\\'" | ||
| end |
There was a problem hiding this comment.
Preserve whitespace-normalized matching in the live preview
For exact, starts_with, and ends_with rules, descriptions with leading or trailing whitespace now disagree between the live panel and the actual apply path: ImportRule#matches? and for_description strip the description, while these SQL predicates compare the untrimmed column. For example, an exact FOO rule will be applied to a description of " FOO " but the editor reports no match; apply the same trimming semantics in this query.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in bdcffff: the live-preview SQL now uses LOWER(TRIM(transactions.description)) to mirror ImportRule#for_description / #matches?, which strip the description before comparing. Added a service test with a padded description.
| # Non-excluded (actionable) rows first, so already-excluded rows can't push movable | ||
| # rows out of the LIMIT window and undercount the footer; then most recent first. | ||
| # (NULLS FIRST keeps it a plain column ref, which SELECT DISTINCT allows in ORDER BY.) | ||
| .order(Arel.sql("transactions.excluded_at ASC NULLS FIRST"), transacted_at: :desc) |
There was a problem hiding this comment.
Order genuinely movable matches before applying the preview limit
When more than 50 non-excluded descriptions match, recent no-op rows—such as transactions already assigned to the selected account or with no identifiable import side—can fill the entire limited window because this ordering distinguishes only excluded rows. Older transactions that would actually move are then omitted and move_count_summary can report zero even though applying the rule changes them, contrary to the movable-first assumption used by the count logic.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Whether a row actually "moves" is decided Ruby-side in change_for/identify_counterpart, so it can't be expressed in the SQL ORDER BY. In bdcffff I kept the excluded-last ordering and made move_count_summary append + whenever the result set is truncated, so the footer reads as an explicit lower bound of the visible window; the Preview modal (RetroactiveApply, unbounded) remains the authoritative count.
| def maybe_apply_retroactively | ||
| return nil if params[:apply_after_save].blank? | ||
|
|
||
| ImportRule::RetroactiveApply.new(user: current_user, rule: @import_rule).apply |
There was a problem hiding this comment.
Surface failures from per-rule retroactive apply
If the new Save & apply flow encounters an ActiveRecord error while excluding, merging, or reassigning a transaction, RetroactiveApply#apply catches it and returns zero, but this caller discards service.errors; the rule remains saved and the user receives the successful Rule saved. notice although none of the previewed changes were applied. Check and propagate the service error as the apply-all action does.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in bdcffff: maybe_apply_retroactively now captures service.errors; on failure the rule stays saved and the notice reads "Rule saved, but the changes couldn't be applied: …" instead of a silent success. Added a mocked controller test.
There was a problem hiding this comment.
Pull request overview
This PR redesigns the Import Rules UI from a table + separate screens into a single split-pane “workbench” using server-rendered Hotwire (Turbo Frames/Streams + Stimulus), adding inline CRUD, live match previewing, drag-to-reorder priority, and native <dialog> preview/apply modals—while keeping the existing ImportRule schema intact.
Changes:
- Replaces Import Rules index/new/edit/preview flows with a split-pane workbench that uses Turbo Frames/Streams for inline editing and list updates.
- Introduces a shared transaction-scoping module and a new
ImportRule::MatchPreviewservice to power consistent live matching + preview/apply behavior. - Adds Stimulus controllers and dedicated styles to support filtering, modal behavior, live preview debouncing, and drag-and-drop reordering.
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/system/import_rules_test.rb | Expands system coverage for the new workbench UX (inline CRUD, filtering, previews/modals). |
| test/services/import_rule/match_preview_test.rb | Adds unit tests for the new live match preview service behavior and edge cases. |
| test/models/import_rule_test.rb | Adds coverage for the new in-memory matches? matcher. |
| test/fixtures/import_rules.yml | Adds an exclude-rule fixture used by filtering/UI tests. |
| test/controllers/import_rules_controller_test.rb | Adds request coverage for turbo_stream CRUD, reorder, match preview, and draft preview/apply flows. |
| config/routes.rb | Moves preview/match_preview/reorder to collection routes and adds endpoints for the workbench. |
| app/views/import_rules/workbench_update.turbo_stream.erb | Turbo Stream template to refresh list/editor after destructive actions. |
| app/views/import_rules/saved.turbo_stream.erb | Turbo Stream template to keep the saved rule open and update the list + flash. |
| app/views/import_rules/preview.html.erb | Converts per-rule preview into modal content suitable for <dialog> + frame loading. |
| app/views/import_rules/preview_apply.html.erb | Converts preview/apply-all into modal content with updated copy and actions. |
| app/views/import_rules/new.html.erb | Simplifies new page to render the frame-friendly editor partial. |
| app/views/import_rules/index.html.erb | Implements the split-pane workbench layout, list tooling, and modal container. |
| app/views/import_rules/edit.html.erb | Simplifies edit page to render the frame-friendly editor partial. |
| app/views/import_rules/apply.turbo_stream.erb | Turbo Stream response for applying rules (updates list/editor and flashes). |
| app/views/import_rules/_test_panel.html.erb | Renders the live match preview panel content. |
| app/views/import_rules/_test_head.html.erb | Header for the live match preview panel. |
| app/views/import_rules/_rule_row.html.erb | New rule list row component with selection, search metadata, and drag hooks. |
| app/views/import_rules/_preview_table.html.erb | Refactors preview rendering into grouped “reassign” vs “exclude” sections. |
| app/views/import_rules/_match_preview.html.erb | Wraps live preview content in the ir_match_preview Turbo Frame. |
| app/views/import_rules/_list.html.erb | New list wrapper rendering rule rows and the empty state. |
| app/views/import_rules/_form.html.erb | Rebuilds the editor UI (match type controls, assign/exclude toggle, live preview, preview modal trigger). |
| app/views/import_rules/_flash.html.erb | Adds dismissible flash partial used by Turbo Stream flash updates. |
| app/views/import_rules/_flash_stream.html.erb | Streams flash updates into the workbench page. |
| app/views/import_rules/_editor_empty.html.erb | Empty editor state when no rule is selected. |
| app/views/import_rules/_change_row.html.erb | Renders an individual “would change” row for preview modals. |
| app/views/import_rules/_account_chip.html.erb | Renders “account chips” (including exclude/no-account states). |
| app/services/import_rule/transaction_scope.rb | Extracts shared transaction scoping + change computation used by apply and preview services. |
| app/services/import_rule/retroactive_apply.rb | Refactors retroactive apply to use the shared transaction scope and draft matching. |
| app/services/import_rule/match_preview.rb | Adds the live match preview service with SQL-side matching and result shaping. |
| app/models/import_rule.rb | Adds matches? in-memory matcher to support draft previews without DB lookup. |
| app/javascript/controllers/import_rules/reorder_controller.js | Adds native HTML5 drag-and-drop reordering with optimistic DOM updates + POST persistence. |
| app/javascript/controllers/import_rules/modal_controller.js | Adds <dialog> modal open/close lifecycle + apply-and-save behavior. |
| app/javascript/controllers/import_rules/live_test_controller.js | Adds debounced live preview frame updates + delayed loading indicator + preview URL syncing. |
| app/javascript/controllers/import_rules/list_filter_controller.js | Adds client-side list filtering (search + All/Assign/Exclude segments) and selection highlight. |
| app/javascript/controllers/import_rules/action_controller.js | Adds assign/exclude toggle behavior with account value restore. |
| app/helpers/import_rules_helper.rb | Adds helper methods for match type labels/phrases and safe match highlighting in previews. |
| app/assets/stylesheets/import_rules.css | Adds scoped styles for the workbench, editor, list, live preview, and modals. |
| app/assets/stylesheets/application.css | Adds shared design tokens used by the workbench (surfaces/shadow/mono font stack). |
| def build_draft_rule | ||
| exclude = ActiveModel::Type::Boolean.new.cast(params[:exclude]) || false | ||
| match_type = ImportRule.match_types.key?(params[:match_type].to_s) ? params[:match_type] : "contains" | ||
| current_user.import_rules.build( | ||
| match_pattern: params[:pattern], | ||
| match_type: match_type, | ||
| account_id: (exclude ? nil : params[:account_id].presence), | ||
| exclude: exclude | ||
| ) |
There was a problem hiding this comment.
Fixed in bdcffff: build_draft_rule now resolves the account via current_user.accounts.find_by(...), so a foreign account_id resolves to nil and can't surface in the un-validated draft preview/apply. Added a controller test ("draft preview ignores another user's account_id").
| def like_clause | ||
| case @match_type | ||
| when "exact" then "LOWER(transactions.description) = :pattern" | ||
| else "LOWER(transactions.description) LIKE :pattern ESCAPE '\\'" | ||
| end |
There was a problem hiding this comment.
Fixed in bdcffff: the live-preview SQL now uses LOWER(TRIM(transactions.description)) to mirror ImportRule#for_description / #matches?, which strip the description before comparing. Added a service test with a padded description.
| respond_to do |format| | ||
| format.html { redirect_to import_rules_path, notice: "#{count} #{"transaction".pluralize(count)} reassigned." } | ||
| format.turbo_stream { flash.now[:notice] = "#{count} #{"transaction".pluralize(count)} reassigned."; render :apply } | ||
| end |
There was a problem hiding this comment.
Fixed in bdcffff: apply notices now say "updated" (the count includes excludes, not only reassignments), which also matches the prototype copy.
| def saved_notice(verb, applied) | ||
| return "Rule was successfully #{verb}." if applied.nil? | ||
| return "Rule saved." if applied.zero? | ||
|
|
||
| "Rule saved · #{applied} #{"transaction".pluralize(applied)} reassigned." | ||
| end |
There was a problem hiding this comment.
Fixed in bdcffff: apply notices now say "updated" (the count includes excludes, not only reassignments), which also matches the prototype copy.
| <div class="ir-seg" role="group" aria-label="Filter rules"> | ||
| <% [ [ "all", "All" ], [ "assign", "Assign" ], [ "exclude", "Exclude" ] ].each_with_index do |(value, label), index| %> | ||
| <button type="button" class="<%= "active" if index.zero? %>" | ||
| data-segment="<%= value %>" | ||
| data-import-rules--list-filter-target="segment" | ||
| data-action="import-rules--list-filter#selectSegment"><%= label %></button> |
There was a problem hiding this comment.
Fixed in bdcffff: added aria-pressed to the segment buttons, and the Stimulus controller now toggles it alongside the active class.
- Scope the draft preview/apply account through current_user.accounts so an enumerated foreign account_id can't be referenced in the un-validated draft (Codex/Copilot P2). - TRIM the description column in the live-preview SQL so exact/starts_with/ ends_with agree with ImportRule#for_description / #matches?, which strip the description before comparing. - Surface failures from per-rule "Save & apply": capture RetroactiveApply's errors and report "Rule saved, but the changes couldn't be applied: ..." instead of a silent success. - Use neutral "updated" wording in apply notices (the count includes excludes, not just reassignments). - Qualify the live-preview move count with "+" whenever the result set is truncated (it's only a lower bound of the visible window; the modal is authoritative). - Add aria-pressed to the filter segment buttons for assistive tech. - Cover the new branches with unit/helper tests (whitespace trim, foreign account scoping, save-and-apply failure, highlight_match per match type, matches? fallback). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
What & why
Replaces the plain Import Rules
<table>+ separate full-page form/preview screens with a single split-pane rules workbench, recreating a Claude Design prototype in idiomatic server-rendered Hotwire (Turbo + Stimulus). No schema changes — the existingmatch_type/exclude/priority/accountmodel is untouched.Highlights
rule_editorTurbo Frame. Create/edit/delete happen inline via Turbo Streams (no page jumps); after a save the editor stays open on the rule and its row stays highlighted. The editor sticks below the header and scrolls internally so it stays usable with a long rule list.ImportRule::MatchPreviewservice that mirrors the rule-matching SQL transaction-side.reorderendpoint.<dialog>modals with the prototype's fade/rise transitions. Per-rule Preview reflects unsaved edits; its Save & apply persists the rule and applies it retroactively in one step (the modal copy adapts to whether the rule is dirty/new vs. clean).ImportRule::RetroactiveApply(the apply path) and the live preview now share anImportRule::TransactionScopemodule, so the editor preview and the modal always agree on what would change (already-excluded rows are shown in the preview but never touched by an apply).Account chips are quiet "Name · Kind" text (no colored dots), and all styles live in a namespaced
app/assets/stylesheets/import_rules.css(loaded via the:appglob).Test plan
All items have coverage:
test/models/import_rule_test.rb) —matches?in-memory matcher across every match type, case-insensitivity.test/services/import_rule/match_preview_test.rb) — match types / escaping / scoping, would-move vs. target/exclude, already-excluded rows listed-but-not-moving, movable-first ordering,LIMIT/+truncation.retroactive_apply_test.rbstill passes against the extracted shared module.test/controllers/import_rules_controller_test.rb) — CRUD (html + json + turbo_stream),reorder,match_preview(matches, blank, excluded), draftpreview(dirty vs. clean), create/update + apply-after-save, and the editor preloading its matches.test/system/import_rules_test.rb) — inline create/edit/delete, selected-row highlight + editor-stays-open, live preview (preloaded, no typing), search + segment filter, toggle-restore of the account, the three preview-modal states (Save & apply / Apply / no changes), apply-from-modal persists + reassigns. (Native HTML5 drag-reorder is covered by the request test, per the unreliable-Selenium-DnD note.)CI green locally:
bin/rails test(917) +bin/rails test:system(77),bin/rubocop,bin/brakemanall pass.🤖 Generated with Claude Code