diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 00fb5d95..6baba784 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -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 */ diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 814ebf13..0a5889fe 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -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 unless @import.loaded? redirect_to load_import_path(@import), alert: t(".invalid_csv") diff --git a/app/javascript/application.js b/app/javascript/application.js index 0d7b4940..b286d5a0 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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); + } +}; diff --git a/app/javascript/controllers/dropzone_controller.js b/app/javascript/controllers/dropzone_controller.js new file mode 100644 index 00000000..ccfe6705 --- /dev/null +++ b/app/javascript/controllers/dropzone_controller.js @@ -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; + } +} diff --git a/app/views/imports/load.html.erb b/app/views/imports/load.html.erb index 8bf7cb82..ef646ca8 100644 --- a/app/views/imports/load.html.erb +++ b/app/views/imports/load.html.erb @@ -9,15 +9,24 @@ <%= form_with model: @import, url: load_import_path(@import) do |form| %> -
You will not be able to undo this decision
" 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. diff --git a/config/routes.rb b/config/routes.rb index eee50831..97397acd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb index bf1ce1ac..25f28c5c 100644 --- a/test/controllers/imports_controller_test.rb +++ b/test/controllers/imports_controller_test.rb @@ -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 diff --git a/test/fixtures/files/valid_csv.csv b/test/fixtures/files/valid_csv.csv new file mode 100644 index 00000000..2a38e4d8 --- /dev/null +++ b/test/fixtures/files/valid_csv.csv @@ -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 \ No newline at end of file diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index 98f9614e..18a5f6c7 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -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