mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 11:34:13 +00:00
Add protection indicator to entries and unlock functionality (#765)
* feat: add protection indicator to entries and unlock functionality - Introduced protection indicator component rendering on hover and in detail views. - Added support to unlock entries, clearing protection flags (`user_modified`, `import_locked`, and locked attributes). - Updated routes, controllers, and models to enable unlock functionality for trades and transactions. - Refactored views and localized content to support the new feature. - Added relevant tests for unlocking functionality and attribute handling. * feat: improve sync protection and turbo stream updates for entries - Added tests for turbo stream updates reflecting protection indicators. - Ensured user-modified entries lock specific attributes to prevent overwrites. - Updated controllers to mark entries as user-modified and reload for accurate rendering. - Enhanced protection indicator rendering using turbo frames. - Applied consistent lock state handling across trades and transactions. * Address PR review comments for protection indicator --------- Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
class TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_entry_for_unlock, only: :unlock
|
||||
|
||||
# Defaults to a buy trade
|
||||
def new
|
||||
@account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@@ -17,6 +19,12 @@ class TradesController < ApplicationController
|
||||
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
|
||||
|
||||
if @model.persisted?
|
||||
# Mark manually created entries as user-modified to protect from sync
|
||||
if @model.is_a?(Entry)
|
||||
@model.lock_saved_attributes!
|
||||
@model.mark_user_modified!
|
||||
end
|
||||
|
||||
flash[:notice] = t("entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
@@ -30,19 +38,28 @@ class TradesController < ApplicationController
|
||||
|
||||
def update
|
||||
if @entry.update(update_entry_params)
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.mark_user_modified!
|
||||
@entry.sync_account_later
|
||||
|
||||
# Reload to ensure fresh state for turbo stream rendering
|
||||
@entry.reload
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_entry_#{@entry.id}",
|
||||
dom_id(@entry, :header),
|
||||
partial: "trades/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry })
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :protection),
|
||||
partial: "entries/protection_indicator",
|
||||
locals: { entry: @entry, unlock_path: unlock_trade_path(@entry.trade) }
|
||||
),
|
||||
turbo_stream.replace(@entry)
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -51,7 +68,19 @@ class TradesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def unlock
|
||||
@entry.unlock_for_sync!
|
||||
flash[:notice] = t("entries.unlock.success")
|
||||
|
||||
redirect_back_or_to account_path(@entry.account)
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry_for_unlock
|
||||
trade = Current.family.trades.find(params[:id])
|
||||
@entry = trade.entry
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class TransactionsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_entry_for_unlock, only: :unlock
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
def new
|
||||
@@ -68,6 +69,7 @@ class TransactionsController < ApplicationController
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.mark_user_modified!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
flash[:notice] = "Transaction created"
|
||||
@@ -98,6 +100,9 @@ class TransactionsController < ApplicationController
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
@entry.sync_account_later
|
||||
|
||||
# Reload to ensure fresh state for turbo stream rendering
|
||||
@entry.reload
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
|
||||
format.turbo_stream do
|
||||
@@ -107,6 +112,11 @@ class TransactionsController < ApplicationController
|
||||
partial: "transactions/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :protection),
|
||||
partial: "entries/protection_indicator",
|
||||
locals: { entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) }
|
||||
),
|
||||
turbo_stream.replace(@entry),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
@@ -206,7 +216,7 @@ class TransactionsController < ApplicationController
|
||||
original_name: @entry.name,
|
||||
original_date: I18n.l(@entry.date, format: :long))
|
||||
|
||||
@entry.account.entries.create!(
|
||||
new_entry = @entry.account.entries.create!(
|
||||
name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker),
|
||||
date: @entry.date,
|
||||
amount: signed_amount,
|
||||
@@ -221,6 +231,10 @@ class TransactionsController < ApplicationController
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the new trade as user-modified to protect from sync
|
||||
new_entry.lock_saved_attributes!
|
||||
new_entry.mark_user_modified!
|
||||
|
||||
# Mark original transaction as excluded (soft delete)
|
||||
@entry.update!(excluded: true)
|
||||
end
|
||||
@@ -235,6 +249,13 @@ class TransactionsController < ApplicationController
|
||||
redirect_back_or_to transactions_path, status: :see_other
|
||||
end
|
||||
|
||||
def unlock
|
||||
@entry.unlock_for_sync!
|
||||
flash[:notice] = t("entries.unlock.success")
|
||||
|
||||
redirect_back_or_to transactions_path
|
||||
end
|
||||
|
||||
def mark_as_recurring
|
||||
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
|
||||
|
||||
@@ -286,6 +307,11 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry_for_unlock
|
||||
transaction = Current.family.transactions.find(params[:id])
|
||||
@entry = transaction.entry
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
return false if Current.user.rule_prompts_disabled
|
||||
|
||||
|
||||
@@ -260,6 +260,50 @@ class Entry < ApplicationRecord
|
||||
update!(user_modified: true)
|
||||
end
|
||||
|
||||
# Returns the reason this entry is protected from sync, or nil if not protected.
|
||||
# Priority: excluded > user_modified > import_locked
|
||||
#
|
||||
# @return [Symbol, nil] :excluded, :user_modified, :import_locked, or nil
|
||||
def protection_reason
|
||||
return :excluded if excluded?
|
||||
return :user_modified if user_modified?
|
||||
return :import_locked if import_locked?
|
||||
nil
|
||||
end
|
||||
|
||||
# Returns array of field names that are locked on entry and entryable.
|
||||
#
|
||||
# @return [Array<String>] locked field names
|
||||
def locked_field_names
|
||||
entry_keys = locked_attributes&.keys || []
|
||||
entryable_keys = entryable&.locked_attributes&.keys || []
|
||||
(entry_keys + entryable_keys).uniq
|
||||
end
|
||||
|
||||
# Returns hash of locked field names to their lock timestamps.
|
||||
# Combines locked_attributes from both entry and entryable.
|
||||
# Parses ISO8601 timestamps stored in locked_attributes.
|
||||
#
|
||||
# @return [Hash{String => Time}] field name to lock timestamp
|
||||
def locked_fields_with_timestamps
|
||||
combined = (locked_attributes || {}).merge(entryable&.locked_attributes || {})
|
||||
combined.transform_values do |timestamp|
|
||||
Time.zone.parse(timestamp.to_s) rescue timestamp
|
||||
end
|
||||
end
|
||||
|
||||
# Clears protection flags so provider sync can update this entry again.
|
||||
# Clears user_modified, import_locked flags, and all locked_attributes
|
||||
# on both the entry and its entryable.
|
||||
#
|
||||
# @return [void]
|
||||
def unlock_for_sync!
|
||||
self.class.transaction do
|
||||
update!(user_modified: false, import_locked: false, locked_attributes: {})
|
||||
entryable&.update!(locked_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
EntrySearch.new(params).build_query(all)
|
||||
|
||||
42
app/views/entries/_protection_indicator.html.erb
Normal file
42
app/views/entries/_protection_indicator.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<%# locals: (entry:, unlock_path:) %>
|
||||
|
||||
<%# Protection indicator - shows when entry is protected from sync overwrites %>
|
||||
<%= turbo_frame_tag dom_id(entry, :protection) do %>
|
||||
<% if entry.protected_from_sync? && !entry.excluded? %>
|
||||
<details class="mx-4 my-3 border border-primary rounded-lg overflow-hidden">
|
||||
<summary class="flex items-center gap-2 cursor-pointer p-3 bg-container hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden">
|
||||
<%= icon "lock", size: "sm", class: "text-secondary" %>
|
||||
<span class="text-sm font-medium text-primary flex-1"><%= t("entries.protection.title") %></span>
|
||||
<%= icon "chevron-down", size: "sm", class: "text-secondary transition-transform [[open]>&]:rotate-180" %>
|
||||
</summary>
|
||||
<div class="p-4 border-t border-primary bg-surface-inset space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t("entries.protection.description") %>
|
||||
</p>
|
||||
|
||||
<% if entry.locked_field_names.any? %>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-secondary"><%= t("entries.protection.locked_fields_label") %></p>
|
||||
<% entry.locked_fields_with_timestamps.each do |field, timestamp| %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-primary"><%= field.humanize %></span>
|
||||
<span class="text-secondary"><%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= link_to unlock_path,
|
||||
class: "w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-secondary text-primary hover:bg-surface-hover transition-colors",
|
||||
data: {
|
||||
turbo_method: :post,
|
||||
turbo_confirm: t("entries.protection.unlock_confirm"),
|
||||
turbo_frame: "_top"
|
||||
} do %>
|
||||
<%= icon "unlock", size: "sm" %>
|
||||
<span><%= t("entries.protection.unlock_button") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= turbo_frame_tag dom_id(trade) do %>
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-primary" %> text-sm font-medium p-4">
|
||||
<div class="group grid grid-cols-12 items-center <%= entry.excluded ? "text-secondary bg-surface-inset" : "text-primary" %> text-sm font-medium p-4">
|
||||
<div class="col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "checkbox checkbox--light hidden lg:block",
|
||||
@@ -44,7 +44,16 @@
|
||||
<%= render "investment_activity/quick_edit_badge", entry: entry, entryable: trade %>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
|
||||
<%# Protection indicator - shows on hover when entry is protected from sync %>
|
||||
<% if entry.protected_from_sync? && !entry.excluded? %>
|
||||
<%= link_to entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "invisible group-hover:visible transition-opacity",
|
||||
title: t("entries.protection.tooltip") do %>
|
||||
<%= icon "lock", size: "sm", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.amount.negative?] %>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<% trade = @entry.trade %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_trade_path(trade) %>
|
||||
|
||||
<% dialog.with_section(title: t(".details"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= turbo_frame_tag dom_id(transaction) do %>
|
||||
<div class="flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
|
||||
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-secondary" : "" %>">
|
||||
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
@@ -145,7 +145,16 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
|
||||
<%# Protection indicator - shows on hover when entry is protected from sync %>
|
||||
<% if entry.protected_from_sync? && !entry.excluded? %>
|
||||
<%= link_to entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "invisible group-hover:visible transition-opacity",
|
||||
title: t("entries.protection.tooltip") do %>
|
||||
<%= icon "lock", size: "sm", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= content_tag :p,
|
||||
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.amount.negative?] %>
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %>
|
||||
|
||||
<% dialog.with_section(title: t(".overview"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
|
||||
@@ -12,3 +12,12 @@ en:
|
||||
loading: Loading entries...
|
||||
update:
|
||||
success: Entry updated
|
||||
unlock:
|
||||
success: Entry unlocked. It may be updated on next sync.
|
||||
protection:
|
||||
tooltip: Protected from sync
|
||||
title: Protected from sync
|
||||
description: Your edits to this entry won't be overwritten by provider sync.
|
||||
locked_fields_label: "Locked fields:"
|
||||
unlock_button: Allow sync to update
|
||||
unlock_confirm: Allow sync to update this entry? Your changes may be overwritten on the next sync.
|
||||
|
||||
@@ -231,7 +231,11 @@ Rails.application.routes.draw do
|
||||
post :reset_security
|
||||
end
|
||||
end
|
||||
resources :trades, only: %i[show new create update destroy]
|
||||
resources :trades, only: %i[show new create update destroy] do
|
||||
member do
|
||||
post :unlock
|
||||
end
|
||||
end
|
||||
resources :valuations, only: %i[show new create update destroy] do
|
||||
post :confirm_create, on: :collection
|
||||
post :confirm_update, on: :member
|
||||
@@ -257,6 +261,7 @@ Rails.application.routes.draw do
|
||||
post :mark_as_recurring
|
||||
post :merge_duplicate
|
||||
post :dismiss_duplicate
|
||||
post :unlock
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -156,4 +156,111 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_enqueued_with job: SyncJob
|
||||
assert_redirected_to account_url(created_entry.account)
|
||||
end
|
||||
|
||||
test "unlock clears protection flags on user-modified entry" do
|
||||
# Mark as protected with locked_attributes on both entry and entryable
|
||||
@entry.update!(user_modified: true, locked_attributes: { "name" => Time.current.iso8601 })
|
||||
@entry.trade.update!(locked_attributes: { "qty" => Time.current.iso8601 })
|
||||
|
||||
assert @entry.reload.protected_from_sync?
|
||||
|
||||
post unlock_trade_path(@entry.trade)
|
||||
|
||||
assert_redirected_to account_path(@entry.account)
|
||||
assert_equal "Entry unlocked. It may be updated on next sync.", flash[:notice]
|
||||
|
||||
@entry.reload
|
||||
assert_not @entry.user_modified?
|
||||
assert_empty @entry.locked_attributes, "Entry locked_attributes should be cleared"
|
||||
assert_empty @entry.trade.locked_attributes, "Trade locked_attributes should be cleared"
|
||||
assert_not @entry.protected_from_sync?
|
||||
end
|
||||
|
||||
test "unlock clears import_locked flag" do
|
||||
@entry.update!(import_locked: true)
|
||||
|
||||
assert @entry.reload.protected_from_sync?
|
||||
|
||||
post unlock_trade_path(@entry.trade)
|
||||
|
||||
assert_redirected_to account_path(@entry.account)
|
||||
@entry.reload
|
||||
assert_not @entry.import_locked?
|
||||
assert_not @entry.protected_from_sync?
|
||||
end
|
||||
|
||||
test "update locks saved attributes" do
|
||||
assert_not @entry.user_modified?
|
||||
assert_empty @entry.trade.locked_attributes
|
||||
|
||||
patch trade_url(@entry), params: {
|
||||
entry: {
|
||||
currency: "USD",
|
||||
entryable_attributes: {
|
||||
id: @entry.entryable_id,
|
||||
qty: 50,
|
||||
price: 25
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@entry.reload
|
||||
assert @entry.user_modified?
|
||||
assert @entry.trade.locked_attributes.key?("qty")
|
||||
assert @entry.trade.locked_attributes.key?("price")
|
||||
end
|
||||
|
||||
test "turbo stream update includes lock icon for protected entry" do
|
||||
assert_not @entry.user_modified?
|
||||
|
||||
patch trade_url(@entry), params: {
|
||||
entry: {
|
||||
currency: "USD",
|
||||
nature: "outflow",
|
||||
entryable_attributes: {
|
||||
id: @entry.entryable_id,
|
||||
qty: 50,
|
||||
price: 25
|
||||
}
|
||||
}
|
||||
}, as: :turbo_stream
|
||||
|
||||
assert_response :success
|
||||
assert_match(/turbo-stream/, response.content_type)
|
||||
# The turbo stream should contain the lock icon link with protection tooltip
|
||||
assert_match(/title="Protected from sync"/, response.body)
|
||||
# And should contain the lock SVG (the path for lock icon)
|
||||
assert_match(/M7 11V7a5 5 0 0 1 10 0v4/, response.body)
|
||||
end
|
||||
|
||||
test "quick edit badge update locks activity label" do
|
||||
assert_not @entry.user_modified?
|
||||
assert_empty @entry.trade.locked_attributes
|
||||
original_label = @entry.trade.investment_activity_label
|
||||
|
||||
# Mimic the quick edit badge JSON request
|
||||
patch trade_url(@entry),
|
||||
params: {
|
||||
entry: {
|
||||
entryable_attributes: {
|
||||
id: @entry.entryable_id,
|
||||
investment_activity_label: original_label == "Buy" ? "Sell" : "Buy"
|
||||
}
|
||||
}
|
||||
}.to_json,
|
||||
headers: {
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "text/vnd.turbo-stream.html"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_match(/turbo-stream/, response.content_type)
|
||||
# The turbo stream should contain the lock icon
|
||||
assert_match(/title="Protected from sync"/, response.body)
|
||||
|
||||
@entry.reload
|
||||
assert @entry.user_modified?, "Entry should be marked as user_modified"
|
||||
assert @entry.trade.locked_attributes.key?("investment_activity_label"), "investment_activity_label should be locked"
|
||||
assert @entry.protected_from_sync?, "Entry should be protected from sync"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -283,4 +283,49 @@ end
|
||||
assert_redirected_to transactions_path
|
||||
assert_equal "An unexpected error occurred while creating the recurring transaction", flash[:alert]
|
||||
end
|
||||
|
||||
test "unlock clears protection flags on user-modified entry" do
|
||||
family = families(:empty)
|
||||
sign_in users(:empty)
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
entry = create_transaction(account: account, amount: 100)
|
||||
transaction = entry.entryable
|
||||
|
||||
# Mark as protected with locked_attributes on both entry and entryable
|
||||
entry.update!(user_modified: true, locked_attributes: { "date" => Time.current.iso8601 })
|
||||
transaction.update!(locked_attributes: { "category_id" => Time.current.iso8601 })
|
||||
|
||||
assert entry.reload.protected_from_sync?
|
||||
|
||||
post unlock_transaction_path(transaction)
|
||||
|
||||
assert_redirected_to transactions_path
|
||||
assert_equal "Entry unlocked. It may be updated on next sync.", flash[:notice]
|
||||
|
||||
entry.reload
|
||||
assert_not entry.user_modified?
|
||||
assert_empty entry.locked_attributes, "Entry locked_attributes should be cleared"
|
||||
assert_empty entry.entryable.locked_attributes, "Transaction locked_attributes should be cleared"
|
||||
assert_not entry.protected_from_sync?
|
||||
end
|
||||
|
||||
test "unlock clears import_locked flag" do
|
||||
family = families(:empty)
|
||||
sign_in users(:empty)
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
entry = create_transaction(account: account, amount: 100)
|
||||
transaction = entry.entryable
|
||||
|
||||
# Mark as import locked
|
||||
entry.update!(import_locked: true)
|
||||
|
||||
assert entry.reload.protected_from_sync?
|
||||
|
||||
post unlock_transaction_path(transaction)
|
||||
|
||||
assert_redirected_to transactions_path
|
||||
entry.reload
|
||||
assert_not entry.import_locked?
|
||||
assert_not entry.protected_from_sync?
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user