PageSourceSearch

https://startupkit.app/assets/controllers/import_upload_controller-43bdc729.js

js startupkit.app collected 2026-10-06 04:56:49 UTC 8,483 bytes, 246 lines download raw bytes

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.