Feature/allow for csv upload in import flow #978
@@ -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 */
|
||||
|
||||
@@ -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 ]
|
||||
|
|
||||
|
||||
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
|
||||
|
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.
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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
183
app/javascript/controllers/dropzone_controller.js
Normal file
183
app/javascript/controllers/dropzone_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
31
app/views/shared/_dropzone.html.erb
Normal file
31
app/views/shared/_dropzone.html.erb
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
5
test/fixtures/files/valid_csv.csv
vendored
Normal 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
|
||||
|
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user
I don't think this is the best practice, however was unsure how to make it work from a JS http request.