1import { Controller } from "@hotwired/stimulus" 2import { Turbo } from "@hotwired/turbo-rails" 3 4// Uploads a dropped CV batch into an import tray, one PDF per request, in order. 5// 6// <div data-controller="import-upload" 7// data-import-upload-url-value="/hiring/job_postings/1/imports/2/files" // String: POST endpoint 8// data-import-upload-max-files-value="200" // Number 9// data-import-upload-max-size-value="10485760" // Number: bytes per file 10// data-import-upload-accept-types-value=".pdf,.docx" // String: allowed extensions 11// data-import-upload-too-many-text-value="â¦" // String 12// data-import-upload-wrong-type-text-value="â¦" // String 13// data-import-upload-too-large-text-value="â¦" // String 14// data-import-upload-failed-text-value="â¦" // String, {name} 15// data-import-upload-progress-text-value="â¦" // String, {done} {total} 16// data-import-upload-aborted-text-value="â¦" // String, {done} {total} 17// data-import-upload-leave-text-value="â¦" // String: beforeunload prompt 18// data-import-upload-dragging-class="import-dropzone--dragging"> 19// <div data-import-upload-target="dropzone" 20// data-action="dragover->import-upload#dragover dragenter->import-upload#dragenter 21// dragleave->import-upload#dragleave drop->import-upload#drop"> 22// <label><input type="file" multiple class="sr-only" data-import-upload-target="input" 23// data-action="change->import-upload#inputChanged"></label> 24// </div> 25// <div data-import-upload-target="status" hidden> 26// <span data-import-upload-target="statusText"></span> 27// <span data-import-upload-target="bar"></span> 28// </div> 29// <button type="button" data-import-upload-target="abort" hidden data-action="import-upload#abort"></button> 30// <p data-import-upload-target="error"></p> 31// </div> 32export default class extends Controller { 33 static targets = ["dropzone", "input", "status", "statusText", "bar", "abort", "error"] 34 static classes = ["dragging"] 35 static values = { 36 url: String, 37 maxFiles: Number, 38 maxSize: Number, 39 acceptTypes: String, 40 tooManyText: String, 41 wrongTypeText: String, 42 tooLargeText: String, 43 failedText: String, 44 progressText: String, 45 abortedText: String, 46 leaveText: String 47 } 48 49 connect() { 50 this.dragCounter = 0 51 this.uploading = false 52 this.aborted = false 53 this.guardLeave = (event) => { event.preventDefault(); event.returnValue = this.leaveTextValue } 54 } 55 56 disconnect() { 57 this.dragCounter = 0 58 this.aborted = true 59 this.request?.abort() 60 window.removeEventListener("beforeunload", this.guardLeave) 61 } 62 63 // A stream replace mid-drag discards the class-bearing node while the counter 64 // is stuck above zero, so the next drag would never reach the highlight threshold. 65 dropzoneTargetDisconnected() { 66 this.dragCounter = 0 67 } 68 69 // page-drop caught this batch before the tray existed; it hands it over here. 70 stage(files) { 71 this.uploadAll(files) 72 } 73 74 dragover(event) { 75 event.preventDefault() 76 } 77 78 dragenter(event) { 79 event.preventDefault() 80 this.dragCounter++ 81 if (this.dragCounter === 1 && this.hasDropzoneTarget) { 82 this.dropzoneTarget.classList.add(...this.draggingClasses) 83 } 84 } 85 86 dragleave(event) { 87 event.preventDefault() 88 this.dragCounter-- 89 if (this.dragCounter === 0 && this.hasDropzoneTarget) { 90 this.dropzoneTarget.classList.remove(...this.draggingClasses) 91 } 92 } 93 94 drop(event) { 95 event.preventDefault() 96 this.dragCounter = 0 97 if (this.hasDropzoneTarget) this.dropzoneTarget.classList.remove(...this.draggingClasses) 98 this.uploadAll(event.dataTransfer.files) 99 } 100 101 inputChanged() { 102 this.uploadAll(this.inputTarget.files) 103 } 104 105 abort() { 106 this.aborted = true 107 this.request?.abort() 108 } 109 110 // Private 111 112 async uploadAll(fileList) { 113 if (this.uploading || !this.urlValue) return 114 115 const files = this.accept(fileList) 116 if (files.length === 0) return 117 118 this.uploading = true 119 this.aborted = false 120 this.expected = files.length 121 window.addEventListener("beforeunload", this.guardLeave) 122 this.dispatch("started") 123 124 let done = 0 125 this.report(done) 126 this.showAbort(true) 127 128 try { 129 // Sequential on purpose: each POST holds a worker thread through the S3 130 // PUT, and prod runs three of them for every tenant at once. 131 for (const file of files) { 132 if (this.aborted) break 133 if (await this.uploadWithRetry(file)) done++ 134 this.report(done) 135 } 136 } finally { 137 this.uploading = false 138 this.showAbort(false) 139 window.removeEventListener("beforeunload", this.guardLeave) 140 if (this.hasInputTarget) this.inputTarget.value = "" 141 if (this.aborted) this.showStatus(this.format(this.abortedTextValue, { done, total: files.length }), done, files.length) 142 this.dispatch("finished") 143 } 144 } 145 146 async uploadWithRetry(file) { 147 // Two retries, then no row is created: the batch list's count is what tells the 148 // recruiter which files never landed. 149 for (let attempt = 0; attempt < 3; attempt++) { 150 if (this.aborted) return false 151 152 const outcome = await this.upload(file) 153 if (outcome === "ok") return true 154 if (outcome === "refused") return false 155 if (attempt < 2) await this.backoff(attempt) 156 } 157 this.showError(this.format(this.failedTextValue, { name: file.name })) 158 return false 159 } 160 161 async upload(file) { 162 const body = new FormData() 163 body.append("file", file) 164 body.append("import[expected_file_count]", this.expected) 165 166 this.request = new AbortController() 167 168 try { 169 const response = await fetch(this.urlValue, { 170 method: "POST", 171 headers: { 172 "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content, 173 "Accept": "text/vnd.turbo-stream.html" 174 }, 175 body, 176 signal: this.request.signal 177 }) 178 179 if ((response.headers.get("Content-Type") || "").includes("turbo-stream")) {
180 Turbo.renderStreamMessage(await response.text()) 181 } 182 183 if (response.ok) return "ok" 184 return (response.status === 429 || response.status >= 500) ? "retry" : "refused" 185 } catch { 186 return "retry" 187 } 188 } 189 190 backoff(attempt) { 191 return new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1) ** 2)) 192 } 193 194 accept(fileList) { 195 const accepted = this.acceptTypesValue.split(",").map(type => type.trim().toLowerCase()) 196 const files = [] 197 198 for (const file of fileList) { 199 if (files.length >= this.maxFilesValue) { 200 this.showError(this.format(this.tooManyTextValue, { max: this.maxFilesValue })) 201 break 202 } 203 if (!accepted.includes(`.${file.name.split(".").pop().toLowerCase()}`)) { 204 this.showError(this.format(this.wrongTypeTextValue, { name: file.name })) 205 continue 206 } 207 if (file.size > this.maxSizeValue) { 208 this.showError(this.format(this.tooLargeTextValue, { name: file.name })) 209 continue 210 } 211 files.push(file) 212 } 213 214 return files 215 } 216 217 report(done) { 218 this.showStatus(this.format(this.progressTextValue, { done, total: this.expected }), done, this.expected) 219 } 220 221 showStatus(text, done, total) { 222 if (this.hasStatusTarget) this.statusTarget.hidden = false 223 if (this.hasStatusTextTarget) this.statusTextTarget.textContent = text 224 if (this.hasBarTarget) this.barTarget.style.width = `${total ? Math.round((done / total) * 100) : 0}%` 225 } 226 227 showAbort(visible) { 228 if (this.hasAbortTarget) this.abortTarget.hidden = !visible 229 } 230 231 // A node, not textContent: the server appends its own refusals here, and 232 // rewriting the text would flatten them onto one line. 233 showError(message) { 234 if (!this.hasErrorTarget) return 235 if ([...this.errorTarget.children].some(line => line.textContent === message)) return 236 237 const line = document.createElement("span") 238 line.className = "block" 239 line.textContent = message 240 this.errorTarget.append(line) 241 } 242 243 format(template, values) { 244 return Object.entries(values).reduce((text, [key, value]) => text.replaceAll(`{${key}}`, value), template) 245 } 246}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.