mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19: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 "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "csv"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[ mri windows ]
|
||||
|
|
|
@ -156,6 +156,7 @@ GEM
|
|||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.2.8)
|
||||
date (3.3.4)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
|
@ -456,6 +457,7 @@ DEPENDENCIES
|
|||
brakeman
|
||||
capybara
|
||||
climate_control
|
||||
csv
|
||||
debug
|
||||
dotenv-rails
|
||||
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
|
||||
layout "with_sidebar"
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[ show update destroy sync ]
|
||||
|
||||
|
@ -48,7 +50,7 @@ class AccountsController < ApplicationController
|
|||
end
|
||||
end
|
||||
else
|
||||
render "edit", status: :unprocessable_entity
|
||||
render "show", status: :unprocessable_entity
|
||||
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
|
||||
layout "with_sidebar"
|
||||
|
||||
include Filterable
|
||||
|
||||
def dashboard
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Settings::BillingsController < ApplicationController
|
||||
class Settings::BillingsController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Settings::HostingsController < ApplicationController
|
||||
class Settings::HostingsController < SettingsController
|
||||
before_action :verify_hosting_mode
|
||||
|
||||
def show
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Settings::NotificationsController < ApplicationController
|
||||
class Settings::NotificationsController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Settings::PreferencesController < ApplicationController
|
||||
class Settings::PreferencesController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
|
@ -14,11 +14,12 @@ class Settings::PreferencesController < ApplicationController
|
|||
redirect_to settings_preferences_path, notice: t(".success")
|
||||
else
|
||||
redirect_to settings_preferences_path, notice: t(".success")
|
||||
render :edit, status: :unprocessable_entity
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preference_params
|
||||
params.require(:user).permit(family_attributes: [ :id, :currency ])
|
||||
end
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Settings::ProfilesController < ApplicationController
|
||||
class Settings::ProfilesController < SettingsController
|
||||
def show
|
||||
end
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Settings::SecuritiesController < ApplicationController
|
||||
class Settings::SecuritiesController < SettingsController
|
||||
def edit
|
||||
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
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Transactions::CategoriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_category, only: %i[ edit update ]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Transactions::MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Transactions::RulesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class TransactionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||
|
||||
def index
|
||||
|
|
|
@ -51,6 +51,11 @@ module ApplicationHelper
|
|||
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)
|
||||
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
|
||||
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 :valuations, dependent: :destroy
|
||||
has_many :transactions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ class Family < ApplicationRecord
|
|||
has_many :users, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :imports, through: :accounts
|
||||
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
||||
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>
|
||||
<html class="h-full bg-gray-25">
|
||||
<html class="h-full">
|
||||
<head>
|
||||
<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="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">
|
||||
<%= 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="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 %>
|
||||
</head>
|
||||
|
||||
<body class="h-full">
|
||||
<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) }) %>
|
||||
<div class="flex h-full">
|
||||
<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>
|
||||
|
||||
<%= content_for?(:content) ? yield(:content) : yield %>
|
||||
|
||||
<%= turbo_frame_tag "modal" %>
|
||||
<%= render "shared/confirm_modal" %>
|
||||
<%= render "shared/upgrade_notification" %>
|
||||
|
||||
<% 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">
|
||||
<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>
|
||||
<%= render "shared/app_version" %>
|
||||
<% end %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,34 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<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">
|
||||
<%= content_for :content do %>
|
||||
<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">
|
||||
<%= 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" %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
<% end %>
|
||||
|
||||
<%= 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 class="flex justify-between gap-4">
|
||||
<%= previous_setting("Rules", transaction_rules_path) %>
|
||||
<%= previous_setting("Imports", imports_path) %>
|
||||
<%= next_setting("Feedback", feedback_path) %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -55,6 +55,9 @@
|
|||
<li>
|
||||
<%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<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" %>
|
||||
<span class="text-black"><%= t(".edit_categories") %></span>
|
||||
<% 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>
|
||||
|
||||
<% 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 %>
|
||||
|
||||
<%= 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") %>
|
||||
<p>New transaction</p>
|
||||
<p class="text-sm font-medium">New transaction</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,6 @@
|
|||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<%= previous_setting("Merchants", transaction_merchants_path) %>
|
||||
<%= next_setting("What's New", changelog_path) %>
|
||||
<%= next_setting("Imports", imports_path) %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -62,4 +62,6 @@ Rails.application.configure do
|
|||
|
||||
# Raise error when a before_action's only/except options reference missing actions
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
config.autoload_paths += %w[ test/support ]
|
||||
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
|
||||
feedback_label: Feedback
|
||||
general_section_title: General
|
||||
imports_label: Imports
|
||||
invite_label: Invite friends
|
||||
merchants_label: Merchants
|
||||
notifications_label: Notifications
|
||||
|
|
|
@ -57,6 +57,8 @@ en:
|
|||
transfer: Transfer
|
||||
index:
|
||||
edit_categories: Edit categories
|
||||
edit_imports: Edit imports
|
||||
import: Import
|
||||
merchants:
|
||||
create:
|
||||
success: New merchant created successfully
|
||||
|
|
|
@ -21,8 +21,24 @@ Rails.application.routes.draw do
|
|||
resource :security, only: %i[show update]
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -18,6 +18,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
|
|||
# Custom types defined in this 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 "import_status", ["pending", "importing", "complete", "failed"]
|
||||
create_enum "user_role", ["admin", "member"]
|
||||
|
||||
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)"
|
||||
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|
|
||||
t.string "token", 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 "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 "imports", "accounts"
|
||||
add_foreign_key "transaction_categories", "families"
|
||||
add_foreign_key "transaction_merchants", "families"
|
||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
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
|
||||
|
||||
|
@ -10,12 +10,18 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
|||
within "form" do
|
||||
fill_in "Email", with: user.email
|
||||
fill_in "Password", with: "password"
|
||||
click_button "Log in"
|
||||
click_on "Log in"
|
||||
end
|
||||
|
||||
# Trigger Capybara's wait mechanism to avoid timing issues with logins
|
||||
find("h1", text: "Dashboard")
|
||||
end
|
||||
|
||||
def sign_out
|
||||
find("#user-menu").click
|
||||
click_button "Logout"
|
||||
|
||||
# Trigger Capybara's wait mechanism to avoid timing issues with logout
|
||||
find("h2", text: "Sign in to your account")
|
||||
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 ],
|
||||
[ "Merchants", "Merchants", transaction_merchants_path ],
|
||||
[ "Rules", "Rules", transaction_rules_path ],
|
||||
[ "Imports", "Imports", imports_path ],
|
||||
[ "What's New", "What's New", changelog_path ],
|
||||
[ "Feedback", "Feedback", feedback_path ],
|
||||
[ "Invite friends", "Invite friends", invites_path ]
|
||||
|
@ -27,6 +28,7 @@ class SettingsTest < ApplicationSystemTestCase
|
|||
end
|
||||
|
||||
private
|
||||
|
||||
def open_settings_from_sidebar
|
||||
find("#user-menu").click
|
||||
click_link "Settings"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue