mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 22:59:39 +02:00
CSV Transaction Imports (#708)
Introduces a basic CSV import module for bulk-importing account transactions. Changes include: - User can load a CSV - User can configure the column mappings for a CSV - Imported CSV shows invalid cells - User can clean up their data directly in the UI - User can see a preview of the import rows and confirm import - Layout refactor + Import nav stepper - System test stability improvements
This commit is contained in:
parent
3d9ff3ad2a
commit
45ae4a9737
71 changed files with 1657 additions and 117 deletions
1
Gemfile
1
Gemfile
|
@ -46,6 +46,7 @@ gem "octokit"
|
||||||
gem "pagy"
|
gem "pagy"
|
||||||
gem "rails-settings-cached"
|
gem "rails-settings-cached"
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
gem "csv"
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem "debug", platforms: %i[ mri windows ]
|
gem "debug", platforms: %i[ mri windows ]
|
||||||
|
|
|
@ -156,6 +156,7 @@ GEM
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
|
csv (3.2.8)
|
||||||
date (3.3.4)
|
date (3.3.4)
|
||||||
debug (1.9.2)
|
debug (1.9.2)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
|
@ -456,6 +457,7 @@ DEPENDENCIES
|
||||||
brakeman
|
brakeman
|
||||||
capybara
|
capybara
|
||||||
climate_control
|
climate_control
|
||||||
|
csv
|
||||||
debug
|
debug
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
erb_lint
|
erb_lint
|
||||||
|
|
BIN
app/assets/images/apple-logo.png
Normal file
BIN
app/assets/images/apple-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
BIN
app/assets/images/empower-logo.jpeg
Normal file
BIN
app/assets/images/empower-logo.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
app/assets/images/mint-logo.jpeg
Normal file
BIN
app/assets/images/mint-logo.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
|
@ -1,4 +1,6 @@
|
||||||
class AccountsController < ApplicationController
|
class AccountsController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
include Filterable
|
include Filterable
|
||||||
before_action :set_account, only: %i[ show update destroy sync ]
|
before_action :set_account, only: %i[ show update destroy sync ]
|
||||||
|
|
||||||
|
@ -48,7 +50,7 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
render "edit", status: :unprocessable_entity
|
render "show", status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
102
app/controllers/imports_controller.rb
Normal file
102
app/controllers/imports_controller.rb
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
require "ostruct"
|
||||||
|
|
||||||
|
class ImportsController < ApplicationController
|
||||||
|
before_action :set_import, except: %i[ index new create ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@imports = Current.family.imports
|
||||||
|
render layout: "with_sidebar"
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@import = Import.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
account = Current.family.accounts.find(params[:import][:account_id])
|
||||||
|
|
||||||
|
@import.update! account: account
|
||||||
|
redirect_to load_import_path(@import), notice: t(".import_updated")
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
account = Current.family.accounts.find(params[:import][:account_id])
|
||||||
|
@import = Import.create!(account: account)
|
||||||
|
|
||||||
|
redirect_to load_import_path(@import), notice: t(".import_created")
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@import.destroy!
|
||||||
|
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
|
||||||
|
end
|
||||||
|
|
||||||
|
def load
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_csv
|
||||||
|
if @import.update(import_params)
|
||||||
|
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
||||||
|
else
|
||||||
|
flash.now[:error] = @import.errors.full_messages.to_sentence
|
||||||
|
render :load, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def configure
|
||||||
|
unless @import.loaded?
|
||||||
|
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_mappings
|
||||||
|
@import.update! import_params(@import.expected_fields.map(&:key))
|
||||||
|
redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean
|
||||||
|
unless @import.loaded?
|
||||||
|
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_csv
|
||||||
|
update_params = import_params[:csv_update]
|
||||||
|
|
||||||
|
@import.update_csv! \
|
||||||
|
row_idx: update_params[:row_idx],
|
||||||
|
col_idx: update_params[:col_idx],
|
||||||
|
value: update_params[:value]
|
||||||
|
|
||||||
|
render :clean
|
||||||
|
end
|
||||||
|
|
||||||
|
def confirm
|
||||||
|
unless @import.cleaned?
|
||||||
|
redirect_to clean_import_path(@import), alert: t(".invalid_data")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish
|
||||||
|
if @import.valid?
|
||||||
|
@import.publish_later
|
||||||
|
redirect_to imports_path, notice: t(".import_published")
|
||||||
|
else
|
||||||
|
flash.now[:error] = t(".invalid_data")
|
||||||
|
render :confirm, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_import
|
||||||
|
@import = Current.family.imports.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_params(permitted_mappings = nil)
|
||||||
|
params.require(:import).permit(:raw_csv_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,4 +1,6 @@
|
||||||
class PagesController < ApplicationController
|
class PagesController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
include Filterable
|
include Filterable
|
||||||
|
|
||||||
def dashboard
|
def dashboard
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class Settings::BillingsController < ApplicationController
|
class Settings::BillingsController < SettingsController
|
||||||
def edit
|
def edit
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class Settings::HostingsController < ApplicationController
|
class Settings::HostingsController < SettingsController
|
||||||
before_action :verify_hosting_mode
|
before_action :verify_hosting_mode
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class Settings::NotificationsController < ApplicationController
|
class Settings::NotificationsController < SettingsController
|
||||||
def edit
|
def edit
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class Settings::PreferencesController < ApplicationController
|
class Settings::PreferencesController < SettingsController
|
||||||
def edit
|
def edit
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -14,11 +14,12 @@ class Settings::PreferencesController < ApplicationController
|
||||||
redirect_to settings_preferences_path, notice: t(".success")
|
redirect_to settings_preferences_path, notice: t(".success")
|
||||||
else
|
else
|
||||||
redirect_to settings_preferences_path, notice: t(".success")
|
redirect_to settings_preferences_path, notice: t(".success")
|
||||||
render :edit, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def preference_params
|
def preference_params
|
||||||
params.require(:user).permit(family_attributes: [ :id, :currency ])
|
params.require(:user).permit(family_attributes: [ :id, :currency ])
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class Settings::ProfilesController < ApplicationController
|
class Settings::ProfilesController < SettingsController
|
||||||
def show
|
def show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class Settings::SecuritiesController < ApplicationController
|
class Settings::SecuritiesController < SettingsController
|
||||||
def edit
|
def edit
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
3
app/controllers/settings_controller.rb
Normal file
3
app/controllers/settings_controller.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
class SettingsController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
end
|
|
@ -1,4 +1,6 @@
|
||||||
class Transactions::Categories::DeletionsController < ApplicationController
|
class Transactions::Categories::DeletionsController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
before_action :set_category
|
before_action :set_category
|
||||||
before_action :set_replacement_category, only: :create
|
before_action :set_replacement_category, only: :create
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class Transactions::CategoriesController < ApplicationController
|
class Transactions::CategoriesController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
before_action :set_category, only: %i[ edit update ]
|
before_action :set_category, only: %i[ edit update ]
|
||||||
before_action :set_transaction, only: :create
|
before_action :set_transaction, only: :create
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class Transactions::MerchantsController < ApplicationController
|
class Transactions::MerchantsController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class Transactions::RulesController < ApplicationController
|
class Transactions::RulesController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
def index
|
def index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class TransactionsController < ApplicationController
|
class TransactionsController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
before_action :set_transaction, only: %i[ show edit update destroy ]
|
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
|
@ -51,6 +51,11 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def return_to_path(params, fallback = root_path)
|
||||||
|
uri = URI.parse(params[:return_to] || fallback)
|
||||||
|
uri.relative? ? uri.path : root_path
|
||||||
|
end
|
||||||
|
|
||||||
def trend_styles(trend)
|
def trend_styles(trend)
|
||||||
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
|
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
|
||||||
return fallback if trend.nil? || trend.direction.flat?
|
return fallback if trend.nil? || trend.direction.flat?
|
||||||
|
|
19
app/helpers/imports_helper.rb
Normal file
19
app/helpers/imports_helper.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
module ImportsHelper
|
||||||
|
def table_corner_class(row_idx, col_idx, rows, cols)
|
||||||
|
return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
|
||||||
|
return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
|
||||||
|
return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
|
||||||
|
return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
def nav_steps(import = Import.new)
|
||||||
|
[
|
||||||
|
{ name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
|
||||||
|
{ name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
|
||||||
|
{ name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
|
||||||
|
{ name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
|
||||||
|
{ name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
7
app/jobs/import_job.rb
Normal file
7
app/jobs/import_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class ImportJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(import)
|
||||||
|
import.publish
|
||||||
|
end
|
||||||
|
end
|
|
@ -9,6 +9,7 @@ class Account < ApplicationRecord
|
||||||
has_many :balances, dependent: :destroy
|
has_many :balances, dependent: :destroy
|
||||||
has_many :valuations, dependent: :destroy
|
has_many :valuations, dependent: :destroy
|
||||||
has_many :transactions, dependent: :destroy
|
has_many :transactions, dependent: :destroy
|
||||||
|
has_many :imports, dependent: :destroy
|
||||||
|
|
||||||
monetize :balance
|
monetize :balance
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ class Family < ApplicationRecord
|
||||||
has_many :users, dependent: :destroy
|
has_many :users, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
has_many :transactions, through: :accounts
|
has_many :transactions, through: :accounts
|
||||||
|
has_many :imports, through: :accounts
|
||||||
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
||||||
has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant"
|
has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant"
|
||||||
|
|
||||||
|
|
161
app/models/import.rb
Normal file
161
app/models/import.rb
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
class Import < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
|
||||||
|
validate :raw_csv_must_be_parsable
|
||||||
|
|
||||||
|
before_save :initialize_csv, if: :should_initialize_csv?
|
||||||
|
|
||||||
|
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
|
||||||
|
|
||||||
|
store_accessor :column_mappings, :define_column_mapping_keys
|
||||||
|
|
||||||
|
scope :ordered, -> { order(created_at: :desc) }
|
||||||
|
|
||||||
|
def publish_later
|
||||||
|
ImportJob.perform_later(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
def loaded?
|
||||||
|
raw_csv_str.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def configured?
|
||||||
|
csv.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def cleaned?
|
||||||
|
loaded? && configured? && csv.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv
|
||||||
|
get_normalized_csv_with_validation
|
||||||
|
end
|
||||||
|
|
||||||
|
def available_headers
|
||||||
|
get_raw_csv.table.headers
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_selected_header_for_field(field)
|
||||||
|
column_mappings&.dig(field) || field.key
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_csv!(row_idx:, col_idx:, value:)
|
||||||
|
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
||||||
|
update! normalized_csv_str: updated_csv.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# Type-specific methods (potential STI inheritance in future when more import types added)
|
||||||
|
def publish
|
||||||
|
update!(status: "importing")
|
||||||
|
|
||||||
|
transaction do
|
||||||
|
generate_transactions.each do |txn|
|
||||||
|
txn.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
update!(status: "complete")
|
||||||
|
rescue => e
|
||||||
|
update!(status: "failed")
|
||||||
|
Rails.logger.error("Import with id #{id} failed: #{e}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def dry_run
|
||||||
|
generate_transactions
|
||||||
|
end
|
||||||
|
|
||||||
|
def expected_fields
|
||||||
|
@expected_fields ||= create_expected_fields
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get_normalized_csv_with_validation
|
||||||
|
return nil if normalized_csv_str.nil?
|
||||||
|
|
||||||
|
csv = Import::Csv.new(normalized_csv_str)
|
||||||
|
|
||||||
|
expected_fields.each do |field|
|
||||||
|
csv.define_validator(field.key, field.validator) if field.validator
|
||||||
|
end
|
||||||
|
|
||||||
|
csv
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_raw_csv
|
||||||
|
return nil if raw_csv_str.nil?
|
||||||
|
Import::Csv.new(raw_csv_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_initialize_csv?
|
||||||
|
raw_csv_str_changed? || column_mappings_changed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_csv
|
||||||
|
generated_csv = generate_normalized_csv(raw_csv_str)
|
||||||
|
self.normalized_csv_str = generated_csv.table.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
|
||||||
|
def generate_normalized_csv(csv_str)
|
||||||
|
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_csv(row_idx, col_idx, value)
|
||||||
|
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
||||||
|
update! normalized_csv_str: updated_csv.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_transactions
|
||||||
|
transactions = []
|
||||||
|
|
||||||
|
csv.table.each do |row|
|
||||||
|
category = account.family.transaction_categories.find_or_initialize_by(name: row["category"])
|
||||||
|
txn = account.transactions.build \
|
||||||
|
name: row["name"] || "Imported transaction",
|
||||||
|
date: Date.iso8601(row["date"]),
|
||||||
|
category: category,
|
||||||
|
amount: BigDecimal(row["amount"]) * -1 # User inputs amounts with opposite signage of our internal representation
|
||||||
|
|
||||||
|
transactions << txn
|
||||||
|
end
|
||||||
|
|
||||||
|
transactions
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_expected_fields
|
||||||
|
date_field = Import::Field.new \
|
||||||
|
key: "date",
|
||||||
|
label: "Date",
|
||||||
|
validator: ->(value) { Import::Field.iso_date_validator(value) }
|
||||||
|
|
||||||
|
name_field = Import::Field.new \
|
||||||
|
key: "name",
|
||||||
|
label: "Name"
|
||||||
|
|
||||||
|
category_field = Import::Field.new \
|
||||||
|
key: "category",
|
||||||
|
label: "Category"
|
||||||
|
|
||||||
|
amount_field = Import::Field.new \
|
||||||
|
key: "amount",
|
||||||
|
label: "Amount",
|
||||||
|
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
||||||
|
|
||||||
|
[ date_field, name_field, category_field, amount_field ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def define_column_mapping_keys
|
||||||
|
expected_fields.each do |field|
|
||||||
|
field.key.to_sym
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_csv_must_be_parsable
|
||||||
|
begin
|
||||||
|
CSV.parse(raw_csv_str || "")
|
||||||
|
rescue CSV::MalformedCSVError
|
||||||
|
errors.add(:raw_csv_str, "is not a valid CSV format")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
74
app/models/import/csv.rb
Normal file
74
app/models/import/csv.rb
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
class Import::Csv
|
||||||
|
def self.parse_csv(csv_str)
|
||||||
|
CSV.parse((csv_str || "").strip, headers: true, converters: [ ->(str) { str.strip } ])
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_with_field_mappings(raw_csv_str, fields, field_mappings)
|
||||||
|
raw_csv = self.parse_csv(raw_csv_str)
|
||||||
|
|
||||||
|
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true do |csv|
|
||||||
|
raw_csv.each do |row|
|
||||||
|
row_values = []
|
||||||
|
|
||||||
|
fields.each do |field|
|
||||||
|
# Finds the column header name the user has designated for the expected field
|
||||||
|
mapped_field_key = field_mappings[field.key] if field_mappings
|
||||||
|
mapped_header = mapped_field_key || field.key
|
||||||
|
|
||||||
|
row_values << row.fetch(mapped_header, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
csv << row_values
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
new(generated_csv_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :csv_str
|
||||||
|
|
||||||
|
def initialize(csv_str, column_validators: nil)
|
||||||
|
@csv_str = csv_str
|
||||||
|
@column_validators = column_validators || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def table
|
||||||
|
@table ||= self.class.parse_csv(csv_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_cell(row_idx, col_idx, value)
|
||||||
|
copy = table.by_col_or_row
|
||||||
|
copy[row_idx][col_idx] = value
|
||||||
|
copy
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid?
|
||||||
|
table.each_with_index.all? do |row, row_idx|
|
||||||
|
row.each_with_index.all? do |cell, col_idx|
|
||||||
|
cell_valid?(row_idx, col_idx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cell_valid?(row_idx, col_idx)
|
||||||
|
value = table.dig(row_idx, col_idx)
|
||||||
|
header = table.headers[col_idx]
|
||||||
|
validator = get_validator_by_header(header)
|
||||||
|
validator.call(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def define_validator(header_key, validator = nil, &block)
|
||||||
|
header = table.headers.find { |h| h.strip == header_key }
|
||||||
|
raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil?
|
||||||
|
|
||||||
|
column_validators[header] = validator || block
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_accessor :column_validators
|
||||||
|
|
||||||
|
def get_validator_by_header(header)
|
||||||
|
column_validators&.dig(header) || ->(_v) { true }
|
||||||
|
end
|
||||||
|
end
|
32
app/models/import/field.rb
Normal file
32
app/models/import/field.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
class Import::Field
|
||||||
|
def self.iso_date_validator(value)
|
||||||
|
Date.iso8601(value)
|
||||||
|
true
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.bigdecimal_validator(value)
|
||||||
|
BigDecimal(value)
|
||||||
|
true
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :key, :label, :validator
|
||||||
|
|
||||||
|
def initialize(key:, label:, validator: nil)
|
||||||
|
@key = key.to_s
|
||||||
|
@label = label
|
||||||
|
@validator = validator
|
||||||
|
end
|
||||||
|
|
||||||
|
def define_validator(validator = nil, &block)
|
||||||
|
@validator = validator || block
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate(value)
|
||||||
|
return true if validator.nil?
|
||||||
|
validator.call(value)
|
||||||
|
end
|
||||||
|
end
|
9
app/views/imports/_empty.html.erb
Normal file
9
app/views/imports/_empty.html.erb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<div class="flex justify-center items-center py-20">
|
||||||
|
<div class="text-center flex flex-col items-center max-w-[300px]">
|
||||||
|
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".message") %></p>
|
||||||
|
<%= link_to new_import_path(enable_type_selector: true), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
||||||
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
|
<span><%= t(".new") %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
7
app/views/imports/_form.html.erb
Normal file
7
app/views/imports/_form.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<%= form_with model: @import do |form| %>
|
||||||
|
<div class="mb-4">
|
||||||
|
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium" %>
|
||||||
|
<% end %>
|
61
app/views/imports/_import.html.erb
Normal file
61
app/views/imports/_import.html.erb
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 mb-1">
|
||||||
|
<p class="text-sm text-gray-900">
|
||||||
|
<%= t(".label", account: import.account.name) %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% if import.pending? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-gray-500 border border-alpha-black-50">
|
||||||
|
<%= t(".in_progress") %>
|
||||||
|
</span>
|
||||||
|
<% elsif import.importing? %>
|
||||||
|
<span class="px-1 py text-xs animate-pulse rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50">
|
||||||
|
<%= t(".uploading") %>
|
||||||
|
</span>
|
||||||
|
<% elsif import.failed? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
|
||||||
|
<%= t(".failed") %>
|
||||||
|
</span>
|
||||||
|
<% elsif import.complete? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
|
||||||
|
<%= t(".complete") %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if import.complete? %>
|
||||||
|
<p class="text-xs text-gray-500"><%= t(".completed_on", datetime: import.updated_at.strftime("%Y-%m-%d")) %></p>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-xs text-gray-500"><%= t(".started_on", datetime: import.created_at.strftime("%Y-%m-%d")) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if import.complete? %>
|
||||||
|
<div class="w-7 h-7 bg-green-500/5 flex items-center justify-center rounded-full">
|
||||||
|
<%= lucide_icon("check", class: "text-green-500 w-4 h-4") %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= contextual_menu do %>
|
||||||
|
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||||
|
<%= link_to edit_import_path(import),
|
||||||
|
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
|
||||||
|
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
|
||||||
|
|
||||||
|
<span><%= t(".edit") %></span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= button_to import_path(import),
|
||||||
|
method: :delete,
|
||||||
|
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
|
||||||
|
data: { turbo_confirm: true } do %>
|
||||||
|
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
|
||||||
|
|
||||||
|
<span><%= t(".delete") %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
</div>
|
18
app/views/imports/_nav_step.html.erb
Normal file
18
app/views/imports/_nav_step.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<% is_current = request.path == step[:path] %>
|
||||||
|
<% text_class = if is_current
|
||||||
|
"text-gray-900"
|
||||||
|
else
|
||||||
|
step[:complete] ? "text-green-600" : "text-gray-500"
|
||||||
|
end %>
|
||||||
|
<% step_class = if is_current
|
||||||
|
"bg-gray-900 text-white"
|
||||||
|
else
|
||||||
|
step[:complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
|
||||||
|
end %>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||||
|
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||||
|
<%= step[:complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : step_idx + 1 %>
|
||||||
|
</span>
|
||||||
|
<span><%= step[:name] %></span>
|
||||||
|
</div>
|
22
app/views/imports/_sample_table.html.erb
Normal file
22
app/views/imports/_sample_table.html.erb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<!--TODO: Once we have more styled tables for reference, refactor and DRY this up -->
|
||||||
|
<div class="grid grid-cols-4 border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white">
|
||||||
|
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tl-md">Date</div>
|
||||||
|
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Name</div>
|
||||||
|
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Category</div>
|
||||||
|
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tr-md">Amount</div>
|
||||||
|
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-01-01</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Amazon</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Shopping</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-24.99</div>
|
||||||
|
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-03-01</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Spotify</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-16.32</div>
|
||||||
|
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-bl-md">2023-01-06</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Acme</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Income</div>
|
||||||
|
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-br-md">151.22</div>
|
||||||
|
</div>
|
90
app/views/imports/_type_selector.html.erb
Normal file
90
app/views/imports/_type_selector.html.erb
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
<div class="p-4 space-y-4 max-w-[420px]">
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="font-medium text-gray-900"><%= t(".import_transactions") %></h2>
|
||||||
|
<button data-action="modal#close">
|
||||||
|
<%= lucide_icon("x", class: "w-5 h-5 text-gray-900") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-gray-25 p-1">
|
||||||
|
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
|
||||||
|
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
|
||||||
|
<li>
|
||||||
|
<% if Current.family.imports.pending.present? %>
|
||||||
|
<%= link_to edit_import_path(Current.family.imports.pending.ordered.first), class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
|
||||||
|
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||||
|
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".resume_latest_import") %>
|
||||||
|
</span>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
<li>
|
||||||
|
<%= link_to new_import_path, class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
|
||||||
|
<div class="bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||||
|
<%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".import_from_csv") %>
|
||||||
|
</span>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
|
||||||
|
<%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".import_from_mint") %>
|
||||||
|
</span>
|
||||||
|
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
|
||||||
|
<%= image_tag("empower-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 border border-alpha-black-100 rounded-md") %>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".import_from_empower") %>
|
||||||
|
</span>
|
||||||
|
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
|
||||||
|
<%= image_tag("apple-logo.png", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".import_from_apple") %>
|
||||||
|
</span>
|
||||||
|
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
49
app/views/imports/clean.html.erb
Normal file
49
app/views/imports/clean.html.erb
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-screen-md w-full py-24">
|
||||||
|
<h1 class="sr-only"><%= t(".clean_import") %></h1>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
|
||||||
|
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".clean_and_edit") %></h2>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".clean_description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-25 rounded-xl p-1 mb-6">
|
||||||
|
<div
|
||||||
|
class="grid items-center uppercase text-xs font-medium text-gray-500 py-3"
|
||||||
|
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
|
||||||
|
<% @import.expected_fields.each do |field| %>
|
||||||
|
<div class="px-5"><%= field.label %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-alpha-black-200 rounded-xl shadow-xs divide-y divide-alpha-black-200">
|
||||||
|
<% @import.csv.table.each_with_index do |row, row_index| %>
|
||||||
|
<div
|
||||||
|
class="grid divide-x divide-alpha-black-200"
|
||||||
|
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
|
||||||
|
<% row.fields.each_with_index do |value, col_index| %>
|
||||||
|
<%= form_with model: @import,
|
||||||
|
builder: ActionView::Helpers::FormBuilder,
|
||||||
|
url: clean_import_url(@import),
|
||||||
|
method: :patch,
|
||||||
|
data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
|
||||||
|
<%= form.fields_for :csv_update do |ff| %>
|
||||||
|
<%= ff.hidden_field :row_idx, value: row_index %>
|
||||||
|
<%= ff.hidden_field :col_idx, value: col_index %>
|
||||||
|
<%= ff.text_field :value, value: value,
|
||||||
|
id: "cell-#{row_index}-#{col_index}",
|
||||||
|
class: "#{@import.csv.cell_valid?(row_index, col_index) ? "focus:border-transparent border-transparent" : "border-red-500"} border px-4 py-3.5 text-sm w-full bg-transparent focus:ring-gray-900 focus:ring-2 focus-visible:outline-none #{table_corner_class(row_index, col_index, @import.csv.table, row.fields)}",
|
||||||
|
data: { "auto-submit-form-target" => "auto" } %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @import.csv.valid? %>
|
||||||
|
<%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
24
app/views/imports/configure.html.erb
Normal file
24
app/views/imports/configure.html.erb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-[400px] w-full py-24 space-y-4">
|
||||||
|
<h1 class="sr-only"><%= t(".configure_title") %></h1>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".configure_subtitle") %></h2>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".configure_description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with model: @import, url: configure_import_path(@import) do |form| %>
|
||||||
|
<div class="mb-4 space-y-4">
|
||||||
|
<%= form.fields_for :column_mappings do |mappings| %>
|
||||||
|
<% @import.expected_fields.each do |field| %>
|
||||||
|
<%= mappings.select field.key,
|
||||||
|
options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)),
|
||||||
|
label: field.label %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
16
app/views/imports/confirm.html.erb
Normal file
16
app/views/imports/confirm.html.erb
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-screen-md w-full py-24">
|
||||||
|
<h1 class="sr-only"><%= t(".confirm_title") %></h1>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
|
||||||
|
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".confirm_subtitle") %></h2>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".confirm_description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 space-y-4">
|
||||||
|
<%= render partial: "imports/transactions/transaction_group", collection: @import.dry_run.group_by(&:date) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
|
||||||
|
</div>
|
10
app/views/imports/edit.html.erb
Normal file
10
app/views/imports/edit.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-[400px] w-full py-56">
|
||||||
|
<h1 class="sr-only"><%= t(".edit_title") %></h1>
|
||||||
|
<div class="space-y-2 mb-6 text-center">
|
||||||
|
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
|
||||||
|
</div>
|
||||||
|
<%= render "form", import: @import %>
|
||||||
|
</div>
|
30
app/views/imports/index.html.erb
Normal file
30
app/views/imports/index.html.erb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<% content_for :sidebar do %>
|
||||||
|
<%= render "settings/nav" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-medium text-gray-900"><%= t(".title") %></h1>
|
||||||
|
<%= link_to new_import_path(enable_type_selector: true), class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
||||||
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
|
<span><%= t(".new") %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||||
|
<% if @imports.empty? %>
|
||||||
|
<%= render partial: "imports/empty" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="rounded-xl bg-gray-25 p-1">
|
||||||
|
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
|
||||||
|
|
||||||
|
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
||||||
|
<%= render @imports.ordered %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<%= previous_setting("Rules", transaction_rules_path) %>
|
||||||
|
<%= next_setting("What's new", changelog_path) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
39
app/views/imports/load.html.erb
Normal file
39
app/views/imports/load.html.erb
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-[450px] w-full py-24 space-y-4">
|
||||||
|
<h1 class="sr-only"><%= t(".load_title") %></h1>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".subtitle") %></h2>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with model: @import, url: load_import_path(@import) do |form| %>
|
||||||
|
<div>
|
||||||
|
<%= form.text_area :raw_csv_str,
|
||||||
|
rows: 10,
|
||||||
|
required: true,
|
||||||
|
placeholder: "Paste your CSV file contents here",
|
||||||
|
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400 p-4" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="bg-alpha-black-25 rounded-xl p-1">
|
||||||
|
<div class="text-gray-500 p-2 mb-2">
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
||||||
|
<p class="text-sm"><%= t(".instructions") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-disc text-sm pl-10">
|
||||||
|
<li><%= t(".requirement1") %></li>
|
||||||
|
<li><%= t(".requirement2") %></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= render partial: "imports/sample_table" %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
16
app/views/imports/new.html.erb
Normal file
16
app/views/imports/new.html.erb
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||||
|
|
||||||
|
<% if params[:enable_type_selector].present? %>
|
||||||
|
<%= modal do %>
|
||||||
|
<%= render "type_selector" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-[400px] w-full py-56">
|
||||||
|
<h1 class="sr-only">New import</h1>
|
||||||
|
<div class="space-y-2 mb-6 text-center">
|
||||||
|
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
|
||||||
|
</div>
|
||||||
|
<%= render "form", import: @import %>
|
||||||
|
</div>
|
15
app/views/imports/show.html.erb
Normal file
15
app/views/imports/show.html.erb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<div class="mx-auto md:w-2/3 w-full flex">
|
||||||
|
<div class="mx-auto">
|
||||||
|
<% if notice.present? %>
|
||||||
|
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render @import %>
|
||||||
|
|
||||||
|
<%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
||||||
|
<div class="inline-block ml-2">
|
||||||
|
<%= button_to "Destroy this import", import_path(@import), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
|
||||||
|
</div>
|
||||||
|
<%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
12
app/views/imports/transactions/_transaction.html.erb
Normal file
12
app/views/imports/transactions/_transaction.html.erb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<%# locals: (transaction:) %>
|
||||||
|
<div class="text-gray-900 flex items-center gap-6 py-4 text-sm font-medium px-4">
|
||||||
|
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
|
||||||
|
|
||||||
|
<div class="w-48">
|
||||||
|
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-auto">
|
||||||
|
<%= content_tag :p, format_money(-transaction.amount), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
|
||||||
|
</div>
|
||||||
|
</div>
|
13
app/views/imports/transactions/_transaction_group.html.erb
Normal file
13
app/views/imports/transactions/_transaction_group.html.erb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<%# locals: (transaction_group:) %>
|
||||||
|
<% date = transaction_group[0] %>
|
||||||
|
<% transactions = transaction_group[1] %>
|
||||||
|
|
||||||
|
<div class="bg-gray-25 rounded-xl p-1 w-full">
|
||||||
|
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
|
<h4><%= date.strftime("%b %d, %Y") %> · <%= transactions.size %></h4>
|
||||||
|
<span><%= format_money -transactions.sum { |t| t.amount } %></span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||||
|
<%= render partial: "imports/transactions/transaction", collection: transactions %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,52 +1,39 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html class="h-full bg-gray-25">
|
<html class="h-full">
|
||||||
<head>
|
<head>
|
||||||
<title><%= content_for(:title) || "Maybe" %></title>
|
<title><%= content_for(:title) || "Maybe" %></title>
|
||||||
|
|
||||||
|
<%= csrf_meta_tags %>
|
||||||
|
<%= csp_meta_tag %>
|
||||||
|
|
||||||
|
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||||
|
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||||
|
|
||||||
|
<%= javascript_importmap_tags %>
|
||||||
|
<%= hotwire_livereload_tags if Rails.env.development? %>
|
||||||
|
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
||||||
|
|
||||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Maybe">
|
<meta name="apple-mobile-web-app-title" content="Maybe">
|
||||||
<%= csrf_meta_tags %>
|
|
||||||
<%= csp_meta_tag %>
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
|
||||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ffffff">
|
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
|
||||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
||||||
<%= javascript_importmap_tags %>
|
|
||||||
<%= hotwire_livereload_tags if Rails.env.development? %>
|
|
||||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="h-full">
|
<body class="h-full">
|
||||||
<div id="notification-tray" class="fixed z-50 space-y-1 top-6 right-6"></div>
|
<div id="notification-tray" class="fixed z-50 space-y-1 top-6 right-6"></div>
|
||||||
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
|
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
|
||||||
<div class="flex h-full">
|
|
||||||
<div class="p-6 pb-20 w-[368px] shrink-0 h-full overflow-y-auto">
|
<%= content_for?(:content) ? yield(:content) : yield %>
|
||||||
<% if content_for?(:sidebar) %>
|
|
||||||
<%= yield :sidebar %>
|
|
||||||
<% else %>
|
|
||||||
<%= render "layouts/sidebar" %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<main class="grow px-20 pt-6 pb-32 h-full overflow-y-auto">
|
|
||||||
<%= yield %>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<%= turbo_frame_tag "modal" %>
|
<%= turbo_frame_tag "modal" %>
|
||||||
<%= render "shared/confirm_modal" %>
|
<%= render "shared/confirm_modal" %>
|
||||||
<%= render "shared/upgrade_notification" %>
|
|
||||||
<% if self_hosted? %>
|
<% if self_hosted? %>
|
||||||
<div class="flex items-center py-0.5 px-0.5 gap-1 fixed bottom-2 right-2 shadow-xs border border-alpha-black-50 rounded-md bg-white">
|
<%= render "shared/app_version" %>
|
||||||
<p class="text-xs text-gray-500 pl-2">Self-hosted Maybe: <%= Maybe.version.to_release_tag %></p>
|
|
||||||
<%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
|
|
||||||
<%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,34 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<%= content_for :content do %>
|
||||||
<html class="h-full bg-white">
|
|
||||||
<head>
|
|
||||||
<title>Maybe</title>
|
|
||||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Maybe">
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
|
||||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ffffff">
|
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
|
||||||
<meta name="theme-color" content="#ffffff">
|
|
||||||
|
|
||||||
<%= csrf_meta_tags %>
|
|
||||||
<%= csp_meta_tag %>
|
|
||||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
|
||||||
|
|
||||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
||||||
<%= javascript_importmap_tags %>
|
|
||||||
|
|
||||||
<%= hotwire_livereload_tags if Rails.env.development? %>
|
|
||||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="h-full">
|
|
||||||
<div class="flex flex-col justify-center min-h-full px-6 py-12">
|
<div class="flex flex-col justify-center min-h-full px-6 py-12">
|
||||||
|
|
||||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<%= render "shared/logo" %>
|
<%= render "shared/logo" %>
|
||||||
|
|
||||||
|
@ -45,7 +16,6 @@
|
||||||
<%= t(".or") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
|
<%= t(".or") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
|
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
|
||||||
|
@ -55,7 +25,7 @@
|
||||||
<div class="p-8 mt-2 text-center">
|
<div class="p-8 mt-2 text-center">
|
||||||
<p class="mt-6 text-sm text-black"><%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> • <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %></p>
|
<p class="mt-6 text-sm text-black"><%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> • <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
<% end %>
|
||||||
</html>
|
|
||||||
|
<%= render template: "layouts/application" %>
|
||||||
|
|
34
app/views/layouts/imports.html.erb
Normal file
34
app/views/layouts/imports.html.erb
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<%= content_for :content do %>
|
||||||
|
<div class="flex items-center justify-between p-8">
|
||||||
|
<%= link_to root_path do %>
|
||||||
|
<%= image_tag "logo.svg", alt: "Maybe", class: "h-[22px]" %>
|
||||||
|
<% end %>
|
||||||
|
<nav>
|
||||||
|
<div>
|
||||||
|
<ul class="flex items-center gap-2">
|
||||||
|
<% nav_steps(@import).each_with_index do |step, idx| %>
|
||||||
|
<li class="group flex items-center gap-2">
|
||||||
|
<% if step[:path].present? %>
|
||||||
|
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
||||||
|
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
|
||||||
|
<% end %>
|
||||||
|
<% if idx < nav_steps.size %>
|
||||||
|
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<%= link_to content_for(:return_to_path) do %>
|
||||||
|
<%= lucide_icon("x", class: "text-gray-500 w-5 h-5") %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= yield %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render template: "layouts/application" %>
|
18
app/views/layouts/with_sidebar.html.erb
Normal file
18
app/views/layouts/with_sidebar.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<%= content_for :content do %>
|
||||||
|
<div class="flex h-full bg-gray-25">
|
||||||
|
<div class="p-6 pb-20 w-[368px] shrink-0 h-full overflow-y-auto">
|
||||||
|
<% if content_for?(:sidebar) %>
|
||||||
|
<%= yield :sidebar %>
|
||||||
|
<% else %>
|
||||||
|
<%= render "layouts/sidebar" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<main class="grow px-20 pt-6 pb-32 h-full overflow-y-auto">
|
||||||
|
<%= yield %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= render "shared/upgrade_notification" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render template: "layouts/application" %>
|
|
@ -9,7 +9,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4">
|
<div class="flex justify-between gap-4">
|
||||||
<%= previous_setting("Rules", transaction_rules_path) %>
|
<%= previous_setting("Imports", imports_path) %>
|
||||||
<%= next_setting("Feedback", feedback_path) %>
|
<%= next_setting("Feedback", feedback_path) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -55,6 +55,9 @@
|
||||||
<li>
|
<li>
|
||||||
<%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
|
<%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section class="space-y-2">
|
<section class="space-y-2">
|
||||||
|
|
6
app/views/shared/_app_version.html.erb
Normal file
6
app/views/shared/_app_version.html.erb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<div class="flex items-center py-0.5 px-0.5 gap-1 fixed bottom-2 right-2 shadow-xs border border-alpha-black-50 rounded-md bg-white">
|
||||||
|
<p class="text-xs text-gray-500 pl-2">Version: <%= Maybe.version.to_release_tag %></p>
|
||||||
|
<%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
|
||||||
|
<%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
|
@ -10,12 +10,24 @@
|
||||||
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
|
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
|
||||||
<span class="text-black"><%= t(".edit_categories") %></span>
|
<span class="text-black"><%= t(".edit_categories") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to imports_path,
|
||||||
|
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
|
||||||
|
<%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
|
||||||
|
<span class="text-black"><%= t(".edit_imports") %></span>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
|
||||||
|
<%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
|
||||||
|
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
<p>New transaction</p>
|
<p class="text-sm font-medium">New transaction</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,6 +10,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4">
|
<div class="flex justify-between gap-4">
|
||||||
<%= previous_setting("Merchants", transaction_merchants_path) %>
|
<%= previous_setting("Merchants", transaction_merchants_path) %>
|
||||||
<%= next_setting("What's New", changelog_path) %>
|
<%= next_setting("Imports", imports_path) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -62,4 +62,6 @@ Rails.application.configure do
|
||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions
|
# Raise error when a before_action's only/except options reference missing actions
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|
||||||
|
config.autoload_paths += %w[ test/support ]
|
||||||
end
|
end
|
||||||
|
|
95
config/locales/views/imports/en.yml
Normal file
95
config/locales/views/imports/en.yml
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
---
|
||||||
|
en:
|
||||||
|
imports:
|
||||||
|
clean:
|
||||||
|
clean_and_edit: Clean and edit your data
|
||||||
|
clean_description: Edit your transactions in the table below. Click on any cell
|
||||||
|
to change the date, name, category, or amount.
|
||||||
|
clean_import: Clean import
|
||||||
|
invalid_csv: Please load a CSV first
|
||||||
|
configure:
|
||||||
|
configure_description: Select the columns that match the necessary data fields,
|
||||||
|
so that the columns in your CSV can be correctly mapped with our format.
|
||||||
|
configure_subtitle: Setup your CSV file
|
||||||
|
configure_title: Configure import
|
||||||
|
confirm_accept: Change mappings
|
||||||
|
confirm_body: Changing your mappings may erase any edits you have made to the
|
||||||
|
CSV so far.
|
||||||
|
confirm_title: Are you sure?
|
||||||
|
invalid_csv: Please load a CSV first
|
||||||
|
next: Next
|
||||||
|
confirm:
|
||||||
|
confirm_description: Preview your transactions below and check to see if there
|
||||||
|
are any changes that are required.
|
||||||
|
confirm_subtitle: Confirm your transactions
|
||||||
|
confirm_title: Confirm import
|
||||||
|
invalid_data: You have invalid data, please fix before continuing
|
||||||
|
create:
|
||||||
|
import_created: Import created
|
||||||
|
destroy:
|
||||||
|
import_destroyed: Import destroyed
|
||||||
|
edit:
|
||||||
|
description_text: Importing transactions can only be done for one account at
|
||||||
|
a time. You will need to go through this process again for other accounts.
|
||||||
|
edit_title: Edit import
|
||||||
|
header_text: Select the account your transactions will belong to
|
||||||
|
empty:
|
||||||
|
message: No imports to show
|
||||||
|
new: New Import
|
||||||
|
form:
|
||||||
|
account: Account
|
||||||
|
next: Next
|
||||||
|
select_account: Select account
|
||||||
|
import:
|
||||||
|
complete: Complete
|
||||||
|
completed_on: Completed on %{datetime}
|
||||||
|
delete: Delete
|
||||||
|
edit: Edit
|
||||||
|
failed: Failed
|
||||||
|
in_progress: In progress
|
||||||
|
label: 'Import for: %{account}'
|
||||||
|
started_on: Started on %{datetime}
|
||||||
|
uploading: Processing rows
|
||||||
|
index:
|
||||||
|
imports: Imports
|
||||||
|
new: New import
|
||||||
|
title: Imports
|
||||||
|
load:
|
||||||
|
confirm_accept: Yep, start over!
|
||||||
|
confirm_body: This will reset your import. Any changes you have made to the
|
||||||
|
CSV will be erased.
|
||||||
|
confirm_title: Are you sure?
|
||||||
|
description: Create a spreadsheet or upload an exported CSV from your financial
|
||||||
|
institution.
|
||||||
|
instructions: Your CSV should have the following columns and formats for the
|
||||||
|
best import experience.
|
||||||
|
load_title: Load import
|
||||||
|
next: Next
|
||||||
|
requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
|
||||||
|
requirement2: Negative transaction is an "outflow" (expense), positive is an
|
||||||
|
"inflow" (income)
|
||||||
|
subtitle: Import your transactions
|
||||||
|
load_csv:
|
||||||
|
import_loaded: Import CSV loaded
|
||||||
|
new:
|
||||||
|
description_text: Importing transactions can only be done for one account at
|
||||||
|
a time. You will need to go through this process again for other accounts.
|
||||||
|
header_text: Select the account your transactions will belong to
|
||||||
|
publish:
|
||||||
|
import_published: Import has started in the background
|
||||||
|
invalid_data: Your import is invalid
|
||||||
|
type_selector:
|
||||||
|
description: You can manually import transactions from CSVs or from other financial
|
||||||
|
apps like Mint, Empower (formerly Personal Capital) or Apple Card.
|
||||||
|
import_from_apple: Import from Apple Card
|
||||||
|
import_from_csv: New import from CSV
|
||||||
|
import_from_empower: Import from Empower
|
||||||
|
import_from_mint: Import from Mint
|
||||||
|
import_transactions: Import transactions
|
||||||
|
resume_latest_import: Resume latest import
|
||||||
|
soon: Soon
|
||||||
|
sources: Sources
|
||||||
|
update:
|
||||||
|
import_updated: Import updated
|
||||||
|
update_mappings:
|
||||||
|
column_mappings_saved: Column mappings saved
|
|
@ -55,6 +55,7 @@ en:
|
||||||
categories_label: Categories
|
categories_label: Categories
|
||||||
feedback_label: Feedback
|
feedback_label: Feedback
|
||||||
general_section_title: General
|
general_section_title: General
|
||||||
|
imports_label: Imports
|
||||||
invite_label: Invite friends
|
invite_label: Invite friends
|
||||||
merchants_label: Merchants
|
merchants_label: Merchants
|
||||||
notifications_label: Notifications
|
notifications_label: Notifications
|
||||||
|
|
|
@ -57,6 +57,8 @@ en:
|
||||||
transfer: Transfer
|
transfer: Transfer
|
||||||
index:
|
index:
|
||||||
edit_categories: Edit categories
|
edit_categories: Edit categories
|
||||||
|
edit_imports: Edit imports
|
||||||
|
import: Import
|
||||||
merchants:
|
merchants:
|
||||||
create:
|
create:
|
||||||
success: New merchant created successfully
|
success: New merchant created successfully
|
||||||
|
|
|
@ -21,8 +21,24 @@ Rails.application.routes.draw do
|
||||||
resource :security, only: %i[show update]
|
resource :security, only: %i[show update]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :imports, except: :show do
|
||||||
|
member do
|
||||||
|
get "load"
|
||||||
|
patch "load" => "imports#load_csv"
|
||||||
|
|
||||||
|
get "configure"
|
||||||
|
patch "configure" => "imports#update_mappings"
|
||||||
|
|
||||||
|
get "clean"
|
||||||
|
patch "clean" => "imports#update_csv"
|
||||||
|
|
||||||
|
get "confirm"
|
||||||
|
patch "confirm" => "imports#publish"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :transactions do
|
resources :transactions do
|
||||||
match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search
|
match "search" => "transactions#search", on: :collection, via: %i[ get post ], as: :search
|
||||||
|
|
||||||
collection do
|
collection do
|
||||||
scope module: :transactions do
|
scope module: :transactions do
|
||||||
|
|
15
db/migrate/20240502205006_create_imports.rb
Normal file
15
db/migrate/20240502205006_create_imports.rb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
class CreateImports < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_enum :import_status, %w[pending importing complete failed]
|
||||||
|
|
||||||
|
create_table :imports, id: :uuid do |t|
|
||||||
|
t.references :account, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.jsonb :column_mappings
|
||||||
|
t.enum :status, enum_type: :import_status, default: "pending"
|
||||||
|
t.string :raw_csv_str
|
||||||
|
t.string :normalized_csv_str
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
15
db/schema.rb
generated
15
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
|
ActiveRecord::Schema[7.2].define(version: 2024_05_02_205006) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -18,6 +18,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
|
||||||
# Custom types defined in this database.
|
# Custom types defined in this database.
|
||||||
# Note that some types may not work with other database engines. Be careful if changing database.
|
# Note that some types may not work with other database engines. Be careful if changing database.
|
||||||
create_enum "account_status", ["ok", "syncing", "error"]
|
create_enum "account_status", ["ok", "syncing", "error"]
|
||||||
|
create_enum "import_status", ["pending", "importing", "complete", "failed"]
|
||||||
create_enum "user_role", ["admin", "member"]
|
create_enum "user_role", ["admin", "member"]
|
||||||
|
|
||||||
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
@ -222,6 +223,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
|
||||||
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
|
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "account_id", null: false
|
||||||
|
t.jsonb "column_mappings"
|
||||||
|
t.enum "status", default: "pending", enum_type: "import_status"
|
||||||
|
t.string "raw_csv_str"
|
||||||
|
t.string "normalized_csv_str"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_imports_on_account_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "token", null: false
|
t.string "token", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
@ -305,6 +317,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "imports", "accounts"
|
||||||
add_foreign_key "transaction_categories", "families"
|
add_foreign_key "transaction_categories", "families"
|
||||||
add_foreign_key "transaction_merchants", "families"
|
add_foreign_key "transaction_merchants", "families"
|
||||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||||
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
|
driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ]
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
@ -10,12 +10,18 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||||
within "form" do
|
within "form" do
|
||||||
fill_in "Email", with: user.email
|
fill_in "Email", with: user.email
|
||||||
fill_in "Password", with: "password"
|
fill_in "Password", with: "password"
|
||||||
click_button "Log in"
|
click_on "Log in"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Trigger Capybara's wait mechanism to avoid timing issues with logins
|
||||||
|
find("h1", text: "Dashboard")
|
||||||
end
|
end
|
||||||
|
|
||||||
def sign_out
|
def sign_out
|
||||||
find("#user-menu").click
|
find("#user-menu").click
|
||||||
click_button "Logout"
|
click_button "Logout"
|
||||||
|
|
||||||
|
# Trigger Capybara's wait mechanism to avoid timing issues with logout
|
||||||
|
find("h2", text: "Sign in to your account")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
141
test/controllers/imports_controller_test.rb
Normal file
141
test/controllers/imports_controller_test.rb
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
include ImportTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
@empty_import = imports(:empty_import)
|
||||||
|
@loaded_import = imports(:loaded_import)
|
||||||
|
@completed_import = imports(:completed_import)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get index" do
|
||||||
|
get imports_url
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
@user.family.imports.ordered.each do |import|
|
||||||
|
assert_select "#" + dom_id(import), count: 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get new" do
|
||||||
|
get new_import_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should create import" do
|
||||||
|
assert_difference("Import.count") do
|
||||||
|
post imports_url, params: { import: { account_id: @user.family.accounts.first.id } }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to load_import_path(Import.ordered.first)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get edit" do
|
||||||
|
get edit_import_url(@empty_import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should update import" do
|
||||||
|
patch import_url(@empty_import), params: { import: { account_id: @empty_import.account_id } }
|
||||||
|
assert_redirected_to load_import_path(@empty_import)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should destroy import" do
|
||||||
|
assert_difference("Import.count", -1) do
|
||||||
|
delete import_url(@empty_import)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to imports_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get load" do
|
||||||
|
get load_import_url(@empty_import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should save raw CSV if valid" do
|
||||||
|
patch load_import_url(@empty_import), params: { import: { raw_csv_str: valid_csv_str } }
|
||||||
|
|
||||||
|
assert_redirected_to configure_import_path(@empty_import)
|
||||||
|
assert_equal "Import CSV loaded", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should flash error message if invalid CSV input" do
|
||||||
|
patch load_import_url(@empty_import), params: { import: { raw_csv_str: malformed_csv_str } }
|
||||||
|
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
assert_equal "Raw csv str is not a valid CSV format", flash[:error]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get configure" do
|
||||||
|
get configure_import_url(@loaded_import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect back to load step with an alert message if not loaded" do
|
||||||
|
get configure_import_url(@empty_import)
|
||||||
|
assert_equal "Please load a CSV first", flash[:alert]
|
||||||
|
assert_redirected_to load_import_path(@empty_import)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should update mappings" do
|
||||||
|
patch configure_import_url(@loaded_import), params: {
|
||||||
|
import: {
|
||||||
|
column_mappings: {
|
||||||
|
date: "date",
|
||||||
|
name: "name",
|
||||||
|
category: "category",
|
||||||
|
amount: "amount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to clean_import_path(@loaded_import)
|
||||||
|
assert_equal "Column mappings saved", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update a cell" do
|
||||||
|
assert_equal @loaded_import.csv.table[0][1], "Starbucks drink"
|
||||||
|
|
||||||
|
patch clean_import_url(@loaded_import), params: {
|
||||||
|
import: {
|
||||||
|
csv_update: {
|
||||||
|
row_idx: 0,
|
||||||
|
col_idx: 1,
|
||||||
|
value: "new_merchant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
@loaded_import.reload
|
||||||
|
assert_equal "new_merchant", @loaded_import.csv.table[0][1]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get clean" do
|
||||||
|
get clean_import_url(@loaded_import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get confirm if all values are valid" do
|
||||||
|
get confirm_import_url(@loaded_import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect back to clean if data is invalid" do
|
||||||
|
@empty_import.update! raw_csv_str: valid_csv_with_invalid_values
|
||||||
|
|
||||||
|
get confirm_import_url(@empty_import)
|
||||||
|
assert_equal "You have invalid data, please fix before continuing", flash[:alert]
|
||||||
|
assert_redirected_to clean_import_path(@empty_import)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should confirm import" do
|
||||||
|
patch confirm_import_url(@loaded_import)
|
||||||
|
assert_redirected_to imports_path
|
||||||
|
assert_equal "Import has started in the background", flash[:notice]
|
||||||
|
end
|
||||||
|
end
|
32
test/fixtures/imports.yml
vendored
Normal file
32
test/fixtures/imports.yml
vendored
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
empty_import:
|
||||||
|
account: checking
|
||||||
|
created_at: <%= 1.minute.ago %>
|
||||||
|
|
||||||
|
loaded_import:
|
||||||
|
account: checking
|
||||||
|
raw_csv_str: |
|
||||||
|
date,name,category,amount
|
||||||
|
2024-01-01,Starbucks drink,Food,20
|
||||||
|
2024-01-02,Amazon stuff,Shopping,200
|
||||||
|
normalized_csv_str: |
|
||||||
|
date,name,category,amount
|
||||||
|
2024-01-01,Starbucks drink,Food,20
|
||||||
|
2024-01-02,Amazon stuff,Shopping,200
|
||||||
|
created_at: <%= 2.days.ago %>
|
||||||
|
|
||||||
|
completed_import:
|
||||||
|
account: checking
|
||||||
|
column_mappings:
|
||||||
|
date: date
|
||||||
|
name: name
|
||||||
|
category: category
|
||||||
|
amount: amount
|
||||||
|
raw_csv_str: |
|
||||||
|
date,name,category,amount
|
||||||
|
2024-01-01,Starbucks drink,Food,20
|
||||||
|
normalized_csv_str: |
|
||||||
|
date,name,category,amount
|
||||||
|
2024-01-01,Starbucks drink,Food,20
|
||||||
|
created_at: <%= 2.days.ago %>
|
||||||
|
|
||||||
|
|
18
test/jobs/import_job_test.rb
Normal file
18
test/jobs/import_job_test.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ImportJobTest < ActiveJob::TestCase
|
||||||
|
include ImportTestHelper
|
||||||
|
|
||||||
|
test "import is published" do
|
||||||
|
import = imports(:empty_import)
|
||||||
|
import.update! raw_csv_str: valid_csv_str
|
||||||
|
|
||||||
|
assert import.pending?
|
||||||
|
|
||||||
|
perform_enqueued_jobs do
|
||||||
|
ImportJob.perform_later(import)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert import.reload.complete?
|
||||||
|
end
|
||||||
|
end
|
87
test/models/import/csv_test.rb
Normal file
87
test/models/import/csv_test.rb
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::CsvTest < ActiveSupport::TestCase
|
||||||
|
include ImportTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@csv = Import::Csv.new(valid_csv_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot define validator for non-existent header" do
|
||||||
|
assert_raises do
|
||||||
|
@csv.define_validator "invalid", method(:validate_iso_date)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "csv with no validators is valid" do
|
||||||
|
assert @csv.cell_valid?(0, 0)
|
||||||
|
assert @csv.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "valid csv values" do
|
||||||
|
@csv.define_validator "date", method(:validate_iso_date)
|
||||||
|
|
||||||
|
assert_equal "2024-01-01", @csv.table[0][0]
|
||||||
|
assert @csv.cell_valid?(0, 0)
|
||||||
|
assert @csv.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid csv values" do
|
||||||
|
invalid_csv = Import::Csv.new valid_csv_with_invalid_values
|
||||||
|
|
||||||
|
invalid_csv.define_validator "date", method(:validate_iso_date)
|
||||||
|
|
||||||
|
assert_equal "invalid_date", invalid_csv.table[0][0]
|
||||||
|
assert_not invalid_csv.cell_valid?(0, 0)
|
||||||
|
assert_not invalid_csv.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updating a cell returns a copy of the original csv" do
|
||||||
|
original_date = "2024-01-01"
|
||||||
|
new_date = "2024-01-01"
|
||||||
|
|
||||||
|
assert_equal original_date, @csv.table[0][0]
|
||||||
|
updated = @csv.update_cell(0, 0, new_date)
|
||||||
|
|
||||||
|
assert_equal original_date, @csv.table[0][0]
|
||||||
|
assert_equal new_date, updated[0][0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create CSV with expected columns and field mappings with validators" do
|
||||||
|
date_field = Import::Field.new \
|
||||||
|
key: "date",
|
||||||
|
label: "Date",
|
||||||
|
validator: method(:validate_iso_date)
|
||||||
|
|
||||||
|
name_field = Import::Field.new \
|
||||||
|
key: "name",
|
||||||
|
label: "Name"
|
||||||
|
|
||||||
|
fields = [ date_field, name_field ]
|
||||||
|
|
||||||
|
raw_csv_str = <<-ROWS
|
||||||
|
date,Custom Field Header,extra_field
|
||||||
|
invalid_date_value,Starbucks drink,Food
|
||||||
|
2024-01-02,Amazon stuff,Shopping
|
||||||
|
ROWS
|
||||||
|
|
||||||
|
mappings = {
|
||||||
|
"name" => "Custom Field Header"
|
||||||
|
}
|
||||||
|
|
||||||
|
csv = Import::Csv.create_with_field_mappings(raw_csv_str, fields, mappings)
|
||||||
|
|
||||||
|
assert_equal %w[ date name ], csv.table.headers
|
||||||
|
assert_equal 2, csv.table.size
|
||||||
|
assert_equal "Amazon stuff", csv.table[1][1]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_iso_date(value)
|
||||||
|
Date.iso8601(value)
|
||||||
|
true
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
28
test/models/import/field_test.rb
Normal file
28
test/models/import/field_test.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::FieldTest < ActiveSupport::TestCase
|
||||||
|
test "key is always a string" do
|
||||||
|
field1 = Import::Field.new label: "Test", key: "test"
|
||||||
|
field2 = Import::Field.new label: "Test2", key: :test2
|
||||||
|
|
||||||
|
assert_equal "test", field1.key
|
||||||
|
assert_equal "test2", field2.key
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can set and override a validator for a field" do
|
||||||
|
field = Import::Field.new \
|
||||||
|
label: "Test",
|
||||||
|
key: "Test",
|
||||||
|
validator: ->(val) { val == 42 }
|
||||||
|
|
||||||
|
assert field.validate(42)
|
||||||
|
assert_not field.validate(41)
|
||||||
|
|
||||||
|
field.define_validator do |value|
|
||||||
|
value == 100
|
||||||
|
end
|
||||||
|
|
||||||
|
assert field.validate(100)
|
||||||
|
assert_not field.validate(42)
|
||||||
|
end
|
||||||
|
end
|
61
test/models/import_test.rb
Normal file
61
test/models/import_test.rb
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ImportTest < ActiveSupport::TestCase
|
||||||
|
include ImportTestHelper, ActiveJob::TestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@empty_import = imports(:empty_import)
|
||||||
|
@loaded_import = imports(:loaded_import)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raw csv input must conform to csv spec" do
|
||||||
|
@empty_import.raw_csv_str = malformed_csv_str
|
||||||
|
assert_not @empty_import.valid?
|
||||||
|
|
||||||
|
@empty_import.raw_csv_str = valid_csv_str
|
||||||
|
assert @empty_import.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update csv value without affecting raw input" do
|
||||||
|
assert_equal "Starbucks drink", @loaded_import.csv.table[0][1]
|
||||||
|
|
||||||
|
prior_raw_csv_str_value = @loaded_import.raw_csv_str
|
||||||
|
prior_normalized_csv_str_value = @loaded_import.normalized_csv_str
|
||||||
|
|
||||||
|
@loaded_import.update_csv! \
|
||||||
|
row_idx: 0,
|
||||||
|
col_idx: 1,
|
||||||
|
value: "new_category"
|
||||||
|
|
||||||
|
assert_equal "new_category", @loaded_import.csv.table[0][1]
|
||||||
|
assert_equal prior_raw_csv_str_value, @loaded_import.raw_csv_str
|
||||||
|
assert_not_equal prior_normalized_csv_str_value, @loaded_import.normalized_csv_str
|
||||||
|
end
|
||||||
|
|
||||||
|
test "publishes later" do
|
||||||
|
assert_enqueued_with(job: ImportJob) do
|
||||||
|
@loaded_import.publish_later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "publishes a valid import" do
|
||||||
|
assert_difference "Transaction.count", 2 do
|
||||||
|
@loaded_import.publish
|
||||||
|
end
|
||||||
|
|
||||||
|
@loaded_import.reload
|
||||||
|
|
||||||
|
assert @loaded_import.complete?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "failed publish results in error status" do
|
||||||
|
@empty_import.update! raw_csv_str: valid_csv_with_invalid_values
|
||||||
|
|
||||||
|
assert_difference "Transaction.count", 0 do
|
||||||
|
@empty_import.publish
|
||||||
|
end
|
||||||
|
|
||||||
|
@empty_import.reload
|
||||||
|
assert @empty_import.failed?
|
||||||
|
end
|
||||||
|
end
|
24
test/support/import_test_helper.rb
Normal file
24
test/support/import_test_helper.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
module ImportTestHelper
|
||||||
|
def valid_csv_str
|
||||||
|
<<-ROWS
|
||||||
|
date,name,category,amount
|
||||||
|
2024-01-01,Starbucks drink,Food,20
|
||||||
|
2024-01-02,Amazon stuff,Shopping,200
|
||||||
|
ROWS
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_csv_with_invalid_values
|
||||||
|
<<-ROWS
|
||||||
|
date,name,category,amount
|
||||||
|
invalid_date,Starbucks drink,Food,invalid_amount
|
||||||
|
ROWS
|
||||||
|
end
|
||||||
|
|
||||||
|
def malformed_csv_str
|
||||||
|
<<-ROWS
|
||||||
|
name,age
|
||||||
|
"John Doe,23
|
||||||
|
"Jane Doe",25
|
||||||
|
ROWS
|
||||||
|
end
|
||||||
|
end
|
113
test/system/imports_test.rb
Normal file
113
test/system/imports_test.rb
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
require "application_system_test_case"
|
||||||
|
|
||||||
|
class ImportsTest < ApplicationSystemTestCase
|
||||||
|
include ImportTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
|
||||||
|
@imports = @user.family.imports.ordered.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can trigger new import from settings" do
|
||||||
|
trigger_import_from_settings
|
||||||
|
verify_import_modal
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can resume existing import from settings" do
|
||||||
|
visit imports_url
|
||||||
|
|
||||||
|
within "#" + dom_id(@imports.first) do
|
||||||
|
click_button
|
||||||
|
click_link "Edit"
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_current_path edit_import_path(@imports.first)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can resume latest import" do
|
||||||
|
trigger_import_from_transactions
|
||||||
|
verify_import_modal
|
||||||
|
|
||||||
|
click_link "Resume latest import"
|
||||||
|
|
||||||
|
assert_current_path edit_import_path(@imports.first)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can perform basic CSV import" do
|
||||||
|
trigger_import_from_settings
|
||||||
|
verify_import_modal
|
||||||
|
|
||||||
|
within "#modal" do
|
||||||
|
click_link "New import from CSV"
|
||||||
|
end
|
||||||
|
|
||||||
|
# 1) Create import step
|
||||||
|
assert_selector "h1", text: "New import"
|
||||||
|
|
||||||
|
within "form" do
|
||||||
|
select "Checking Account", from: "import_account_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
click_button "Next"
|
||||||
|
|
||||||
|
# 2) Load Step
|
||||||
|
assert_selector "h1", text: "Load import"
|
||||||
|
|
||||||
|
within "form" do
|
||||||
|
fill_in "import_raw_csv_str", with: <<-ROWS
|
||||||
|
date,Custom Name Column,category,amount
|
||||||
|
invalid_date,Starbucks drink,Food,-20.50
|
||||||
|
2024-01-01,Amazon purchase,Shopping,-89.50
|
||||||
|
ROWS
|
||||||
|
end
|
||||||
|
|
||||||
|
click_button "Next"
|
||||||
|
|
||||||
|
# 3) Configure step
|
||||||
|
assert_selector "h1", text: "Configure import"
|
||||||
|
|
||||||
|
within "form" do
|
||||||
|
select "Custom Name Column", from: "import_column_mappings_name"
|
||||||
|
end
|
||||||
|
|
||||||
|
click_button "Next"
|
||||||
|
|
||||||
|
# 4) Clean step
|
||||||
|
assert_selector "h1", text: "Clean import"
|
||||||
|
|
||||||
|
# We have an invalid value, so user cannot click next yet
|
||||||
|
assert_no_text "Next"
|
||||||
|
|
||||||
|
# Replace invalid date with valid date
|
||||||
|
fill_in "cell-0-0", with: "2024-01-02"
|
||||||
|
|
||||||
|
# Trigger blur event so value saves
|
||||||
|
find("body").click
|
||||||
|
|
||||||
|
click_link "Next"
|
||||||
|
|
||||||
|
# 5) Confirm step
|
||||||
|
assert_selector "h1", text: "Confirm import"
|
||||||
|
click_button "Import 2 transactions"
|
||||||
|
assert_selector "h1", text: "Imports"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def trigger_import_from_settings
|
||||||
|
visit imports_url
|
||||||
|
click_link "New import"
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_import_from_transactions
|
||||||
|
visit transactions_url
|
||||||
|
click_link "Import"
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_import_modal
|
||||||
|
within "#modal" do
|
||||||
|
assert_text "Import transactions"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -14,6 +14,7 @@ class SettingsTest < ApplicationSystemTestCase
|
||||||
[ "Categories", "Categories", transaction_categories_path ],
|
[ "Categories", "Categories", transaction_categories_path ],
|
||||||
[ "Merchants", "Merchants", transaction_merchants_path ],
|
[ "Merchants", "Merchants", transaction_merchants_path ],
|
||||||
[ "Rules", "Rules", transaction_rules_path ],
|
[ "Rules", "Rules", transaction_rules_path ],
|
||||||
|
[ "Imports", "Imports", imports_path ],
|
||||||
[ "What's New", "What's New", changelog_path ],
|
[ "What's New", "What's New", changelog_path ],
|
||||||
[ "Feedback", "Feedback", feedback_path ],
|
[ "Feedback", "Feedback", feedback_path ],
|
||||||
[ "Invite friends", "Invite friends", invites_path ]
|
[ "Invite friends", "Invite friends", invites_path ]
|
||||||
|
@ -27,6 +28,7 @@ class SettingsTest < ApplicationSystemTestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def open_settings_from_sidebar
|
def open_settings_from_sidebar
|
||||||
find("#user-menu").click
|
find("#user-menu").click
|
||||||
click_link "Settings"
|
click_link "Settings"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue