Feature/allow for csv upload in import flow #978

Closed
MagnusHJensen wants to merge 11 commits from feature/allow-for-csv-upload-in-import-flow into main
12 changed files with 312 additions and 12 deletions

View File

@@ -77,6 +77,14 @@
@apply font-bold;
}
}
.dropzone-drag-over > button {
@apply bg-gray-50;
}
.text-as-button {
@apply text-black py-1.5 px-3 border rounded-lg w-fit cursor-pointer;
}
}
/* Small, single purpose classes that should take precedence over other styles */

View File

@@ -2,6 +2,7 @@ require "ostruct"
class ImportsController < ApplicationController
before_action :set_import, except: %i[ index new create ]
protect_from_forgery with: :null_session, include: %i[ upload_csv ]
MagnusHJensen commented 2024-07-12 22:03:52 +08:00 (Migrated from github.com)
Review

I don't think this is the best practice, however was unsure how to make it work from a JS http request.

I don't think this is the best practice, however was unsure how to make it work from a JS http request.
def index
@imports = Current.family.imports
@@ -47,6 +48,24 @@ class ImportsController < ApplicationController
end
end
def upload_csv
raw_csv = params[:file].read
begin
csv = Import::Csv.new(raw_csv)
if csv.valid?
render turbo_stream: turbo_stream.action(
:update_input, "raw_csv_text_area", raw_csv
)
else
render json: { message: "CSV contents is not valid" }, status: :unprocessable_entity
end
rescue CSV::MalformedCSVError => error
file_extension = params[:file].path.split(".").last
render json: { message: "Expected file format CSV, but recieved #{file_extension}", error: error }, status: :bad_request
end
end
def configure
MagnusHJensen commented 2024-07-12 22:04:28 +08:00 (Migrated from github.com)
Review

The errors are not translated, since they are only logged in the console here, which could be picked up by a service like Sentry.

The dropzone does translate errors as seen in the translation file.

The errors are not translated, since they are only logged in the console here, which could be picked up by a service like Sentry. The dropzone does translate errors as seen in the translation file.
zachgoll commented 2024-07-13 01:00:21 +08:00 (Migrated from github.com)
Review

No worries, think this is okay!

No worries, think this is okay!
unless @import.loaded?
redirect_to load_import_path(@import), alert: t(".invalid_csv")

View File

@@ -1,3 +1,16 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "@hotwired/turbo-rails";
import "controllers";
import { Turbo } from "@hotwired/turbo-rails";
/**
* This stream action can be used for updating any input field with a value, based on the content provided.
* It also triggers an input event for any updated inputs.
*/
Turbo.StreamActions.update_input = function () {
const inputEvent = new Event("input", { bubbles: true });
for (const element of this.targetElements) {
element.value = this.templateContent.textContent;
element.dispatchEvent(inputEvent);
}
};

View File

@@ -0,0 +1,183 @@
import { Controller } from "@hotwired/stimulus";
import { Turbo } from "@hotwired/turbo-rails";
// Connects to data-controller="dropzone"
export default class extends Controller {
static targets = [
"fileInput",
"wrapper",
"progressWrapper",
"errorWrapper",
"readyWrapper",
"cancelButton",
"successIcon",
"tryAgainButton",
];
static classes = ["dragOver"];
static values = { url: String };
connect() {
this.element.addEventListener("dragover", this.preventDragDefaults);
this.element.addEventListener("dragenter", this.preventDragDefaults);
this.element.addEventListener("dragover", this._dragOverStyle.bind(this)); // Style dropzone when dragging over
this.element.addEventListener("dragleave", this._dragLeaveStyle.bind(this)); // Remove styling from dropzone
}
disconnect() {
this.element.removeEventListener("dragover", this.preventDragDefaults);
this.element.removeEventListener("dragenter", this.preventDragDefaults);
}
preventDragDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
trigger() {
this.fileInputTarget.click();
}
acceptFiles(event) {
event.preventDefault();
const files = event.dataTransfer
? event.dataTransfer.files
: event.target.files;
const file = files[0];
this._uploadFile(file);
}
_setupCancelButton(xhr) {
this.cancelButtonTarget.addEventListener(
"click",
(e) => {
e.stopPropagation();
xhr.abort();
this._cancelUpload();
},
{ once: true }
);
}
_setupTryAgainButton(xhr) {
this.tryAgainButtonTarget.addEventListener(
"click",
(e) => {
e.stopPropagation();
xhr.abort();
this._cancelUpload();
},
{ once: true }
);
}
// The actual cancelling of the upload, which handles resetting state.
_cancelUpload() {
this.readyWrapperTarget.classList.remove("hidden");
this.progressWrapperTarget.classList.add("hidden");
this.errorWrapperTarget.classList.add("hidden");
this._resetProgress();
}
// Implement your own file upload strategy here...
_uploadFile(file) {
this.element.classList.remove(this.dragOverClass);
this._createXmlHttpRequest(file);
}
_createXmlHttpRequest(file) {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("loadstart", () => {
this._changeToProgress(file.name);
});
xhr.upload.addEventListener("progress", (event) => {
this._updateProgress(event);
});
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status < 300) {
Turbo.renderStreamMessage(xhr.responseText);
this._changeToSuccess(file.name);
return;
}
const response = JSON.parse(xhr.responseText);
if (xhr.status === 400) {
console.error(
`A bad request was provided, this is most likely due to wrong file format.\nError message: "${response.message}"\nActual error: "${response.error}"`
);
this._changeToError();
} else if (xhr.status === 422) {
console.error("Uploaded CSV is not valid", response.message);
this._changeToError();
}
}
};
const fileData = new FormData();
fileData.append("file", file);
xhr.open("POST", this.urlValue);
this._setupCancelButton(xhr);
this._setupTryAgainButton(xhr);
xhr.send(fileData);
}
_dragOverStyle(e) {
e.stopPropagation();
this.element.classList.add(this.dragOverClass);
}
_dragLeaveStyle(e) {
e.stopPropagation();
this.element.classList.remove(this.dragOverClass);
}
_changeToProgress(fileName) {
this.readyWrapperTarget.classList.add("hidden");
this.progressWrapperTarget.classList.remove("hidden");
this.progressWrapperTarget.children[1].innerText = `Uploading ${fileName}`;
this.progressWrapperTarget.children[2].children[0].style.width = "0%";
this.progressWrapperTarget.children[3].innerText = "0%";
this.wrapperTarget.disabled = true;
}
_updateProgress(event) {
const progress = ((event.loaded / event.total) * 100).toFixed(2);
this.progressWrapperTarget.children[2].children[0].style.width = `${progress}%`;
this.progressWrapperTarget.children[3].innerText = `${Math.round(
progress
)}%`;
}
_resetProgress() {
this.progressWrapperTarget.children[1].innerText = "";
this.progressWrapperTarget.children[2].children[0].style.width = "0%";
this.wrapperTarget.disabled = false;
this.successIconTarget.classList.add("hidden");
}
_changeToSuccess(fileName) {
this.successIconTarget.classList.remove("hidden");
this.progressWrapperTarget.children[1].innerText = `Succesfully uploaded ${fileName}`;
}
_changeToError() {
this.errorWrapperTarget.classList.remove("hidden");
this.progressWrapperTarget.classList.add("hidden");
this._resetProgress();
this.wrapperTarget.disabled = true;
}
}

View File

@@ -9,15 +9,24 @@
</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 data-controller="tabs" data-tabs-active-class="bg-white border-alpha-black-25 shadow-xs text-gray-900" data-tabs-default-tab-value="csv-file">
<div class="bg-gray-25 w-fit rounded-lg p-1 flex gap-1 text-sm text-gray-500 font-medium mx-auto mb-4">
<button data-id="csv-file" class="w-fit px-2 py-1 rounded-md border border-transparent" data-tabs-target="btn" data-action="tabs#select" type="button"><%= t(".file_upload_tab") %></button>
<button data-id="copy-paste-csv" class="w-fit px-2 py-1 rounded-md border border-transparent" data-tabs-target="btn" data-action="tabs#select" type="button"><%= t(".copy_paste_tab") %></button>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<div data-tabs-target="tab" id="copy-paste-csv" class="space-y-6">
<%= form.text_area :raw_csv_str,
rows: 10,
required: true,
id: "raw_csv_text_area",
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>
<div data-tabs-target="tab" id="csv-file" class="space-y-6 hidden">
<%= render "shared/dropzone", body_text: t(".drag_and_drop_csv"), upload_url: load_csv_file_import_url(@import) %>
</div>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700", 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">

View File

@@ -0,0 +1,31 @@
<%# locals: (body_text:, upload_url:) %>
<% body_text = local_assigns[:body_text].nil? ? t(".drag_and_drop_default") : local_assigns[:body_text] %>
<% upload_url = local_assigns[:upload_url] %>
<div class="w-full">
<div id="dropzone" data-controller="dropzone" data-action="click->dropzone#trigger drop->dropzone#acceptFiles" data-dropzone-drag-over-class="dropzone-drag-over" data-dropzone-url-value="<%= upload_url %>">
<button
data-dropzone-target="wrapper"
class="rounded-md w-full bg-muted border border-dashed border-gray-300 h-60 px-20 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" type="button">
<div id="ready-dropzone" class="w-4/5 mx-auto" data-dropzone-target="readyWrapper">
<%= lucide_icon("plus", class: "mx-auto w-5 h-5 text-gray-500") %>
<p><span class="mt-3 text-gray-500"><%= body_text %></span><span> <%= t(".click_to_browse") %></span></p>
</div>
<div id="error-dropzone" class="w-4/5 mx-auto hidden" data-dropzone-target="errorWrapper">
<%= lucide_icon("x", class: "mx-auto w-6 h-6 p-1 text-white bg-red-600 rounded-full") %>
<p class="mt-3"><%= t(".error.title") %></p><p class="text-gray-500"> <%= t(".error.body") %></p>
<p class="text-as-button mx-auto mt-7" data-dropzone-target="tryAgainButton"><%= t(".error.try_again") %></p>
</div>
<div id="progress-dropzone" data-dropzone-target="progressWrapper" class="hidden">
<%= lucide_icon("check", class: "hidden w-6 h-6 p-1 text-white bg-success rounded-full mx-auto", data: { "dropzone-target": "successIcon" }) %>
<p class="mt-4"></p>
<div id="file-upload-progress-bg" class="bg-gray-100 w-full h-2 rounded-2xl mt-4">
<div id="file-upload-progress-overlay" class="rounded-2xl bg-success h-full" style="width: 37.5%;"></div>
</div>
<p class="mt-4 text-gray-500">0%</p>
<p class="text-as-button mx-auto mt-7" data-dropzone-target="cancelButton"><%= t(".uploading.cancel") %></p>
</div>
</button>
<input type="file" class="hidden" data-dropzone-target="fileInput" data-action="change->dropzone#acceptFiles" accept="text/csv">
</div>
</div>

View File

@@ -60,8 +60,11 @@ en:
confirm_body: This will reset your import. Any changes you have made to the
CSV will be erased.
confirm_title: Are you sure?
copy_paste_tab: Copy & Paste
description: Create a spreadsheet or upload an exported CSV from your financial
institution.
drag_and_drop_csv: Drag and drop your CSV file here or
file_upload_tab: Upload CSV
instructions: Your CSV should have the following columns and formats for the
best import experience.
load_title: Load import

View File

@@ -6,6 +6,16 @@ en:
body_html: "<p>You will not be able to undo this decision</p>"
cancel: Cancel
title: Are you sure?
dropzone:
click_to_browse: click to browse.
drag_and_drop_default: Drag and drop your file here or
error:
body: This could be due to an incorrect file format or other issues listed
here.
title: There was a problem with your upload
try_again: Try again
uploading:
cancel: Cancel
no_account_empty_state:
new_account: New account
no_account_subtitle: Since no accounts have been added, there's no data to display.

View File

@@ -25,6 +25,7 @@ Rails.application.routes.draw do
member do
get "load"
patch "load" => "imports#load_csv"
post "load-csv-file" => "imports#upload_csv"
get "configure"
patch "configure" => "imports#update_mappings"

View File

@@ -65,13 +65,27 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Import CSV loaded", flash[:notice]
end
test "should flash error message if invalid CSV input" do
test "should flash error message if invalid raw 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 save raw CSV if CSV file is valid" do
post load_csv_file_import_url(@empty_import), xhr: true, params: { file: fixture_file_upload("valid_csv.csv") }
assert_response :success
end
test "should return error message if CSV file is not a CSV file" do
post load_csv_file_import_url(@empty_import), xhr: true, params: { file: fixture_file_upload("profile_image.png") }
assert_response :bad_request
assert_equal "Expected file format CSV, but recieved png", JSON.parse(response.body)["message"], "Error message for invalid file is incorrect"
assert_not_nil JSON.parse(response.body)["error"], "Error attribute in JSON response is nil"
end
test "should get configure" do
get configure_import_url(@loaded_import)
assert_response :success

5
test/fixtures/files/valid_csv.csv vendored Normal file
View File

@@ -0,0 +1,5 @@
date,name,category,tags,amount
2024-01-01,Starbucks drink,Food & Drink,Tag1|Tag2,-8.55
2024-01-01,Etsy,Shopping,Tag1,-80.98
2024-01-02,Amazon stuff,Shopping,Tag2,-200
2024-01-03,Paycheck,Income,,1000
1 date name category tags amount
2 2024-01-01 Starbucks drink Food & Drink Tag1|Tag2 -8.55
3 2024-01-01 Etsy Shopping Tag1 -80.98
4 2024-01-02 Amazon stuff Shopping Tag2 -200
5 2024-01-03 Paycheck Income 1000

View File

@@ -55,7 +55,11 @@ class ImportsTest < ApplicationSystemTestCase
assert_selector "h1", text: "Load import"
within "form" do
fill_in "import_raw_csv_str", with: <<-ROWS
click_button "Copy & Paste"
end
within "form" do
fill_in "raw_csv_text_area", with: <<-ROWS
date,Custom Name Column,category,amount
invalid_date,Starbucks drink,Food,-20.50
2024-01-01,Amazon purchase,Shopping,-89.50