Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Redesign Import Rules as an interactive workbench#200

Open
kramerc wants to merge 2 commits into
mainfrom
redesign-import-rules-workbench
Open

Redesign Import Rules as an interactive workbench#200
kramerc wants to merge 2 commits into
mainfrom
redesign-import-rules-workbench

Conversation

@kramerc

@kramerc kramerc commented Jun 10, 2026

Copy link
Copy Markdown
Owner

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 existing match_type / exclude / priority / account model is untouched.

Highlights

  • Split-pane workbench — a searchable + filterable (All / Assign / Exclude) rule list on the left, an inline editor on the right loaded into a rule_editor Turbo 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.
  • Live pattern testing — typing a pattern shows the matching ledger transactions with the matched text highlighted and "now in {account}" notes. It's preloaded server-side when the editor opens (no "start typing" flash + round-trip), debounced on edit with a delayed loading indicator, and lists already-excluded matches with a clear badge. Powered by a new ImportRule::MatchPreview service that mirrors the rule-matching SQL transaction-side.
  • Drag-to-reorder priority — priority is now positional (the numeric field is gone): native HTML5 drag-and-drop posts to a new reorder endpoint.
  • Preview modals — per-rule Preview and Preview & apply all as native <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).
  • Shared change-detectionImportRule::RetroactiveApply (the apply path) and the live preview now share an ImportRule::TransactionScope module, 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 :app glob).

Test plan

All items have coverage:

  • Model (test/models/import_rule_test.rb) — matches? in-memory matcher across every match type, case-insensitivity.
  • Service (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.rb still passes against the extracted shared module.
  • Controller (test/controllers/import_rules_controller_test.rb) — CRUD (html + json + turbo_stream), reorder, match_preview (matches, blank, excluded), draft preview (dirty vs. clean), create/update + apply-after-save, and the editor preloading its matches.
  • System (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/brakeman all pass.

🤖 Generated with Claude Code

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]>
Copilot AI review requested due to automatic review settings June 10, 2026 05:41
@kramerc kramerc added enhancement New feature or request AI generated This was generated by an AI agent (Claude, Codex, Copilot, etc.) labels Jun 10, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in Honeyledger Jun 10, 2026
@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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").

Comment on lines +121 to +124
case @match_type
when "exact" then "LOWER(transactions.description) = :pattern"
else "LOWER(transactions.description) LIKE :pattern ESCAPE '\\'"
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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::MatchPreview service 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).

Comment on lines +153 to +161
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
)

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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").

Comment on lines +120 to +124
def like_clause
case @match_type
when "exact" then "LOWER(transactions.description) = :pattern"
else "LOWER(transactions.description) LIKE :pattern ESCAPE '\\'"
end

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +109 to +112
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

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in bdcffff: apply notices now say "updated" (the count includes excludes, not only reassignments), which also matches the prototype copy.

Comment on lines +134 to +139
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

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in bdcffff: apply notices now say "updated" (the count includes excludes, not only reassignments), which also matches the prototype copy.

Comment on lines +59 to +64
<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>

@kramerc kramerc Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI generated This was generated by an AI agent (Claude, Codex, Copilot, etc.) enhancement New feature or request

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants