bard-attachment_field 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb0a5828862fbe5feb5e9da0492d9e23dc3698a099168c09e04a90e83f40c493
4
- data.tar.gz: f868efb0bb5eac969ecb0d6ed809c5385b7c8111b6c68e43ec64a8f6e91c7ae1
3
+ metadata.gz: 32a9e349c2ce7715b8a5732e730bbd96efb6c50f542b0f39fd470a1b39b2952e
4
+ data.tar.gz: 1da2861ccab4b81ffc92f0dc6b933c9266b07ba2b96747469d3030de9f44f9f2
5
5
  SHA512:
6
- metadata.gz: 36b03a42ab7d9820fcb049f3406dbe2fe47bb57c13d73b2c102f2373194ed23eb22dee20360ada23f2d9618fa29cf789973702aae22f28a346212b39c11c26c7
7
- data.tar.gz: 24f650bbdc0c0468a371e579255b52cc5a18cbfa5511d92e17671a9903383ad62e9f4ca9abbd73b9ca2c1b5997e4b895f1cb46a1a357f1a3e8f48be93f73f64d
6
+ metadata.gz: 26808a1afc2c0156726a2fb3443a600efb194bdf87ad4d9a284896e0a53ed24ba3cae9170875fa7bb27cc933de8eafa71fc5116669531ab3431329c59ec6c323
7
+ data.tar.gz: 0f48786c5c1eea01995a6e8d04e41f3cb24c5866456b99068d1a4cf6bbedf41e5879df2d02b4f28b02bb5b939f454a9230e2f7bf7e0a49c5a09ef618b200a2cd
@@ -5070,6 +5070,9 @@ var FetchResponse = class {
5070
5070
  get isTurboStream() {
5071
5071
  return this.contentType.match(/^text\/vnd\.turbo-stream\.html/);
5072
5072
  }
5073
+ get isScript() {
5074
+ return this.contentType.match(/\b(?:java|ecma)script\b/);
5075
+ }
5073
5076
  async renderTurboStream() {
5074
5077
  if (this.isTurboStream) {
5075
5078
  if (window.Turbo) {
@@ -5081,6 +5084,22 @@ var FetchResponse = class {
5081
5084
  return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));
5082
5085
  }
5083
5086
  }
5087
+ async activeScript() {
5088
+ if (this.isScript) {
5089
+ const script = document.createElement("script");
5090
+ const metaTag = document.querySelector("meta[name=csp-nonce]");
5091
+ if (metaTag) {
5092
+ const nonce = metaTag.nonce === "" ? metaTag.content : metaTag.nonce;
5093
+ if (nonce) {
5094
+ script.setAttribute("nonce", nonce);
5095
+ }
5096
+ }
5097
+ script.innerHTML = await this.text;
5098
+ document.body.appendChild(script);
5099
+ } else {
5100
+ return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));
5101
+ }
5102
+ }
5084
5103
  };
5085
5104
  var RequestInterceptor = class {
5086
5105
  static register(interceptor) {
@@ -5126,7 +5145,7 @@ function stringEntriesFromFormData(formData) {
5126
5145
  function mergeEntries(searchParams, entries) {
5127
5146
  for (const [name, value] of entries) {
5128
5147
  if (value instanceof window.File) continue;
5129
- if (searchParams.has(name)) {
5148
+ if (searchParams.has(name) && !name.includes("[]")) {
5130
5149
  searchParams.delete(name);
5131
5150
  searchParams.set(name, value);
5132
5151
  } else {
@@ -5149,11 +5168,16 @@ var FetchRequest = class {
5149
5168
  } catch (error) {
5150
5169
  console.error(error);
5151
5170
  }
5152
- const response = new FetchResponse(await window.fetch(this.url, this.fetchOptions));
5171
+ const fetch = window.Turbo ? window.Turbo.fetch : window.fetch;
5172
+ const response = new FetchResponse(await fetch(this.url, this.fetchOptions));
5153
5173
  if (response.unauthenticated && response.authenticationURL) {
5154
5174
  return Promise.reject(window.location.href = response.authenticationURL);
5155
5175
  }
5156
- if (response.ok && response.isTurboStream) {
5176
+ if (response.isScript) {
5177
+ await response.activeScript();
5178
+ }
5179
+ const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity;
5180
+ if (responseStatusIsTurboStreamable && response.isTurboStream) {
5157
5181
  await response.renderTurboStream();
5158
5182
  }
5159
5183
  return response;
@@ -5163,27 +5187,38 @@ var FetchRequest = class {
5163
5187
  headers[key] = value;
5164
5188
  this.options.headers = headers;
5165
5189
  }
5190
+ sameHostname() {
5191
+ if (!this.originalUrl.startsWith("http:") && !this.originalUrl.startsWith("https:")) {
5192
+ return true;
5193
+ }
5194
+ try {
5195
+ return new URL(this.originalUrl).hostname === window.location.hostname;
5196
+ } catch (_) {
5197
+ return true;
5198
+ }
5199
+ }
5166
5200
  get fetchOptions() {
5167
5201
  return {
5168
5202
  method: this.method.toUpperCase(),
5169
5203
  headers: this.headers,
5170
5204
  body: this.formattedBody,
5171
5205
  signal: this.signal,
5172
- credentials: "same-origin",
5173
- redirect: this.redirect
5206
+ credentials: this.credentials,
5207
+ redirect: this.redirect,
5208
+ keepalive: this.keepalive
5174
5209
  };
5175
5210
  }
5176
5211
  get headers() {
5212
+ const baseHeaders = {
5213
+ "X-Requested-With": "XMLHttpRequest",
5214
+ "Content-Type": this.contentType,
5215
+ Accept: this.accept
5216
+ };
5217
+ if (this.sameHostname()) {
5218
+ baseHeaders["X-CSRF-Token"] = this.csrfToken;
5219
+ }
5177
5220
  return compact(
5178
- Object.assign(
5179
- {
5180
- "X-Requested-With": "XMLHttpRequest",
5181
- "X-CSRF-Token": this.csrfToken,
5182
- "Content-Type": this.contentType,
5183
- Accept: this.accept
5184
- },
5185
- this.additionalHeaders
5186
- )
5221
+ Object.assign(baseHeaders, this.additionalHeaders)
5187
5222
  );
5188
5223
  }
5189
5224
  get csrfToken() {
@@ -5207,6 +5242,8 @@ var FetchRequest = class {
5207
5242
  return "text/vnd.turbo-stream.html, text/html, application/xhtml+xml";
5208
5243
  case "json":
5209
5244
  return "application/json, application/vnd.api+json";
5245
+ case "script":
5246
+ return "text/javascript, application/javascript";
5210
5247
  default:
5211
5248
  return "*/*";
5212
5249
  }
@@ -5241,6 +5278,12 @@ var FetchRequest = class {
5241
5278
  get redirect() {
5242
5279
  return this.options.redirect || "follow";
5243
5280
  }
5281
+ get credentials() {
5282
+ return this.options.credentials || "same-origin";
5283
+ }
5284
+ get keepalive() {
5285
+ return this.options.keepalive || false;
5286
+ }
5244
5287
  get additionalHeaders() {
5245
5288
  return this.options.headers || {};
5246
5289
  }
@@ -5256,7 +5299,7 @@ var FetchRequest = class {
5256
5299
  var request = (verb, url, payload, headers) => {
5257
5300
  const req = new FetchRequest(verb, url, {
5258
5301
  headers: { Accept: "application/json", ...headers },
5259
- body: payload
5302
+ ...{ query: payload }
5260
5303
  });
5261
5304
  return req.perform().then((response) => {
5262
5305
  if (response.response.ok) {
@@ -5466,6 +5509,8 @@ var FormController = class _FormController {
5466
5509
  }
5467
5510
  }
5468
5511
  submit(event) {
5512
+ if (this.controllers.length === 0 && !this.hasUploadErrors())
5513
+ return;
5469
5514
  event.preventDefault();
5470
5515
  this.submitted = true;
5471
5516
  this.startNextController();
@@ -5814,11 +5859,13 @@ var InputAttachment$1 = /* @__PURE__ */ proxyCustomElement(class InputAttachment
5814
5859
  set value(val) {
5815
5860
  const newValue = val || [];
5816
5861
  if (JSON.stringify(this.value) !== JSON.stringify(newValue)) {
5817
- this.files = newValue.map((signedId) => Object.assign(new AttachmentFile(), {
5818
- name: this.name,
5819
- preview: this.preview,
5820
- signedId
5821
- }));
5862
+ this.files = newValue.map((signedId) => {
5863
+ const attachmentFile = document.createElement("attachment-file");
5864
+ attachmentFile.name = this.name;
5865
+ attachmentFile.preview = this.preview;
5866
+ attachmentFile.signedId = signedId;
5867
+ return attachmentFile;
5868
+ });
5822
5869
  }
5823
5870
  }
5824
5871
  // For form-persistence: store complete attachment data (not just signed_ids)
@@ -5912,12 +5959,12 @@ var InputAttachment$1 = /* @__PURE__ */ proxyCustomElement(class InputAttachment
5912
5959
  return this.disabled || !!this.el.closest("fieldset[disabled]");
5913
5960
  }
5914
5961
  render() {
5915
- return h(Host, { key: "c71c6869000f6fc105fe66f4417c4e64c3c70f68" }, h("input", { key: "4d03f907860f42f2c30d971360bd3ff9ffe19659", ref: (el) => this.fileInput = el, type: "file", multiple: this.multiple, accept: this.accepts, required: this.required && this.files.length === 0, disabled: this.isDisabled, onChange: () => this.handleFileInputChange(), style: {
5962
+ return h(Host, { key: "4ef61015f6e0abb87d50a3f9ff8e39f01dc44ead" }, h("input", { key: "fc02ff04ff705c2c26ef6bc9e21e23435eefe0d6", ref: (el) => this.fileInput = el, type: "file", multiple: this.multiple, accept: this.accepts, required: this.required && this.files.length === 0, disabled: this.isDisabled, onChange: () => this.handleFileInputChange(), style: {
5916
5963
  opacity: "0.01",
5917
5964
  width: "1px",
5918
5965
  height: "1px",
5919
5966
  zIndex: "-999"
5920
- } }), h("file-drop", { key: "46a5e8eb22825afe03ce5e6e735d59143dec596a", onClick: () => this.fileInput?.click(), onDrop: this.handleDrop }, h("p", { key: "c68e8157ec03306c00e03330fc39d3bdbec0b38b", part: "title" }, h("strong", { key: "c4803833fe77c48882b3414c1f375b050c0659a0" }, "Choose ", this.multiple ? "files" : "file", " "), h("span", { key: "844abd650924319ae4836d2c6be9c13925232229" }, "or drag ", this.multiple ? "them" : "it", " here.")), h("div", { key: "2f355ee03d3bc033fd37d392555ad3f890ef22cb", class: `media-preview ${this.multiple ? "-stacked" : ""}` }, h("slot", { key: "255e79285dd1575386418ffd34f2517c5d7c717a" }))));
5967
+ } }), h("file-drop", { key: "20ad744ab366a7af2404711a025240515198815f", onClick: () => this.fileInput?.click(), onDrop: this.handleDrop }, h("p", { key: "c5289e9d5b03ced01d95f2d2152ab06dfaaaa8fb", part: "title" }, h("strong", { key: "ed48073458e73fad729caebc1eea8c1ed31fd021" }, "Choose ", this.multiple ? "files" : "file", " "), h("span", { key: "daa4fbd6f33180060a306fdb5ea469d2a68a141b" }, "or drag ", this.multiple ? "them" : "it", " here.")), h("div", { key: "83a5aaf4ee4bc0d7c2917a781ef77568ce9fb9a6", class: `media-preview ${this.multiple ? "-stacked" : ""}` }, h("slot", { key: "8ce39f9805a8f292cc08c9d77c8f91d2754152f7" }))));
5921
5968
  }
5922
5969
  componentDidRender() {
5923
5970
  if (this.files.length === 0) {
Binary file
@@ -32,6 +32,7 @@
32
32
  "@botandrose/file-drop": "^0.1.0",
33
33
  "@botandrose/progress-bar": "^0.1.1",
34
34
  "@rails/activestorage": "^8.1.0",
35
+ "@rails/request.js": "0.0.13",
35
36
  "@stencil/core": "^4.38.2",
36
37
  "rails-request-json": "^0.2.0",
37
38
  "ts-jest": "^29.4.5"
@@ -56,6 +56,7 @@ export default class FormController {
56
56
  }
57
57
 
58
58
  submit(event) {
59
+ if(this.controllers.length === 0 && !this.hasUploadErrors()) return
59
60
  event.preventDefault()
60
61
  this.submitted = true
61
62
  this.startNextController()
@@ -1,6 +1,5 @@
1
1
  import { Component, Element, Prop, Listen, Host, h, forceUpdate } from '@stencil/core';
2
2
  import FormController from "./form-controller"
3
- import { AttachmentFile } from "../attachment-file/attachment-file"
4
3
  import { arrayRemove } from "../../utils/utils"
5
4
  import '@botandrose/file-drop'
6
5
  import '@botandrose/progress-bar'
@@ -125,11 +124,13 @@ export class InputAttachment {
125
124
  set value(val) {
126
125
  const newValue = val || []
127
126
  if(JSON.stringify(this.value) !== JSON.stringify(newValue)) { // this is insane. javascript is fucking garbage.
128
- this.files = newValue.map(signedId => Object.assign(new AttachmentFile(), {
129
- name: this.name,
130
- preview: this.preview,
131
- signedId,
132
- }))
127
+ this.files = newValue.map(signedId => {
128
+ const attachmentFile = document.createElement('attachment-file') as any
129
+ attachmentFile.name = this.name
130
+ attachmentFile.preview = this.preview
131
+ attachmentFile.signedId = signedId
132
+ return attachmentFile
133
+ })
133
134
  }
134
135
  }
135
136
 
@@ -24,7 +24,11 @@ module Bard::AttachmentField::TestHelper
24
24
  end
25
25
 
26
26
  def find_field(session, label)
27
- label_element = session.find("label", text: /^#{Regexp.escape(label)}$/)
27
+ label_element = begin
28
+ session.find("label", exact_text: label, visible: :all, match: :first)
29
+ rescue Capybara::ElementNotFound
30
+ session.find("label", text: /^#{Regexp.escape(label)}/, visible: :all, match: :first)
31
+ end
28
32
  element_id = label_element[:for]
29
33
  session.find("input-attachment##{element_id}")
30
34
  end
@@ -34,7 +38,7 @@ module Bard::AttachmentField::TestHelper
34
38
  def attach_files(session, element_id, file_paths)
35
39
  session.execute_script("document.body.insertAdjacentHTML('beforeend', '<input type=\"file\" id=\"_cdp_file_helper\" multiple style=\"display:none\">')")
36
40
 
37
- temp_input = session.find("#_cdp_file_helper", visible: :all)
41
+ temp_input = session.document.find("#_cdp_file_helper", visible: :all)
38
42
  temp_input.native.node.select_file(file_paths)
39
43
 
40
44
  session.execute_script(<<~JS)
@@ -47,12 +51,43 @@ module Bard::AttachmentField::TestHelper
47
51
  JS
48
52
  end
49
53
 
54
+ def wait_for_files(session, element_id, minimum, timeout: 15)
55
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+ loop do
57
+ count = session.evaluate_script("document.getElementById('#{element_id}').querySelectorAll('attachment-file').length")
58
+ break if count >= minimum
59
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
60
+ raise "Expected at least #{minimum} attachment-file(s) in ##{element_id}, found #{count} after #{timeout}s" if elapsed > timeout
61
+ sleep 0.1
62
+ end
63
+ end
64
+
65
+ def wait_for_no_files(session, element_id, selector = "attachment-file", timeout: 10)
66
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ loop do
68
+ count = session.evaluate_script("document.getElementById('#{element_id}').querySelectorAll('#{selector}').length")
69
+ break if count == 0
70
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
71
+ raise "Expected no #{selector} in ##{element_id}, found #{count} after #{timeout}s" if elapsed > timeout
72
+ sleep 0.1
73
+ end
74
+ end
75
+
50
76
  def wait_for_upload(session, element_id, timeout: 30)
51
- session.document.synchronize(timeout, errors: [RuntimeError]) do
52
- states = session.evaluate_script(<<~JS)
53
- Array.from(document.getElementById('#{element_id}').querySelectorAll('attachment-file')).map(e => e.getAttribute('state'))
77
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ loop do
79
+ result = session.evaluate_script(<<~JS)
80
+ (() => {
81
+ const files = document.getElementById('#{element_id}').querySelectorAll('attachment-file');
82
+ return Array.from(files).map(e => ({ state: e.getAttribute('state'), value: e.value }));
83
+ })()
54
84
  JS
55
- raise "Uploads not complete (states=#{states})" unless states.all? { |s| s == "complete" || s == "error" }
85
+ states_done = result.all? { |f| f["state"] == "complete" || f["state"] == "error" }
86
+ values_set = result.all? { |f| f["state"] == "error" || (f["value"] && !f["value"].empty?) }
87
+ break if states_done && values_set
88
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
89
+ raise "Uploads not complete after #{timeout}s (files=#{result})" if elapsed > timeout
90
+ sleep 0.1
56
91
  end
57
92
  end
58
93
 
@@ -116,6 +151,7 @@ class Chop::Form::AttachmentField < Chop::Form::Field
116
151
  end
117
152
 
118
153
  Bard::AttachmentField::TestHelper.attach_files(session, field[:id], file_paths)
154
+ Bard::AttachmentField::TestHelper.wait_for_files(session, field[:id], file_paths.length)
119
155
  Bard::AttachmentField::TestHelper.wait_for_upload(session, field[:id])
120
156
  end
121
157
  end
@@ -123,29 +159,32 @@ end
123
159
  # Step definitions
124
160
 
125
161
  When "I attach the file {string} to {string}" do |path, field|
126
- element = Bard::AttachmentField::TestHelper.find_field(page, field)
127
162
  file_path_full = Bard::AttachmentField::TestHelper.resolve_fixture_path(path)
128
163
 
129
- Bard::AttachmentField::TestHelper.attach_files(page, element[:id], [file_path_full])
130
-
131
- page.document.synchronize(15, errors: [Capybara::ElementNotFound]) do
132
- element.find("attachment-file")
164
+ begin
165
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
166
+ rescue Capybara::ElementNotFound
167
+ attach_file field, file_path_full
168
+ next
133
169
  end
134
170
 
171
+ Bard::AttachmentField::TestHelper.attach_files(page, element[:id], [file_path_full])
172
+ Bard::AttachmentField::TestHelper.wait_for_files(page, element[:id], 1)
135
173
  Bard::AttachmentField::TestHelper.wait_for_upload(page, element[:id])
136
174
  end
137
175
 
138
176
  When "I attach the following files to {string}:" do |field, table|
139
177
  files = table.raw.map(&:first).map { |filename| Bard::AttachmentField::TestHelper.resolve_fixture_path(filename) }
140
178
 
141
- element = Bard::AttachmentField::TestHelper.find_field(page, field)
142
-
143
- Bard::AttachmentField::TestHelper.attach_files(page, element[:id], files)
144
-
145
- page.document.synchronize(15, errors: [Capybara::ElementNotFound]) do
146
- element.all("attachment-file", minimum: files.length)
179
+ begin
180
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
181
+ rescue Capybara::ElementNotFound
182
+ attach_file field, files
183
+ next
147
184
  end
148
185
 
186
+ Bard::AttachmentField::TestHelper.attach_files(page, element[:id], files)
187
+ Bard::AttachmentField::TestHelper.wait_for_files(page, element[:id], files.length)
149
188
  Bard::AttachmentField::TestHelper.wait_for_upload(page, element[:id], timeout: 60)
150
189
  end
151
190
 
@@ -161,11 +200,7 @@ When "I remove the file from {string}" do |field|
161
200
  removeLink.click();
162
201
  })(arguments[0])
163
202
  JS
164
- # Wait for the file to be removed (refetch element to avoid stale reference)
165
- page.document.synchronize(5, errors: [Capybara::ElementNotFound]) do
166
- fresh_element = page.find("##{element_id}")
167
- expect(fresh_element).to have_no_css("attachment-file")
168
- end
203
+ Bard::AttachmentField::TestHelper.wait_for_no_files(page, element_id)
169
204
  end
170
205
 
171
206
  When "I remove {string} from {string}" do |filename, field|
@@ -180,11 +215,21 @@ When "I remove {string} from {string}" do |filename, field|
180
215
  removeLink.click();
181
216
  })(arguments[0], arguments[1])
182
217
  JS
183
- # Wait for the file to be removed (refetch element to avoid stale reference)
184
- page.document.synchronize(5, errors: [Capybara::ElementNotFound]) do
185
- fresh_element = page.find("##{element_id}")
186
- expect(fresh_element).to have_no_css("attachment-file[filename='#{filename}']")
187
- end
218
+ Bard::AttachmentField::TestHelper.wait_for_no_files(page, element_id, "attachment-file[filename='#{filename}']")
219
+ end
220
+
221
+ When "I remove {string} from the {string} field" do |filename, field|
222
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
223
+ element_id = element[:id]
224
+ page.execute_script(<<~JS, element_id, filename)
225
+ ((elementId, filename) => {
226
+ const host = document.getElementById(elementId);
227
+ const attachmentFile = host.querySelector(`attachment-file[filename='${filename}']`);
228
+ const removeLink = attachmentFile.shadowRoot.querySelector('a.remove-media');
229
+ removeLink.click();
230
+ })(arguments[0], arguments[1])
231
+ JS
232
+ Bard::AttachmentField::TestHelper.wait_for_no_files(page, element_id, "attachment-file[filename='#{filename}']")
188
233
  end
189
234
 
190
235
  When "I drag the file {string} onto the {string} attachment field" do |path, field|
@@ -227,6 +272,13 @@ Then "I should see a preview of {string} for the {string} field" do |filename, f
227
272
  expect(element.find("attachment-file")[:filename]).to eq(filename)
228
273
  end
229
274
 
275
+ Then "I should see the following media previews for the {string} field:" do |field, table|
276
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
277
+ element.assert_selector("attachment-file", minimum: table.raw.length)
278
+ actual = Bard::AttachmentField::TestHelper.get_files(element).map { |f| [f] }
279
+ table.diff! actual
280
+ end
281
+
230
282
  When "I follow the {string} download link for {string}" do |filename, field|
231
283
  element = Bard::AttachmentField::TestHelper.find_field(page, field)
232
284
  # Click the download link inside attachment-file's shadow DOM
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Bard
4
4
  module AttachmentField
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bard-attachment_field
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-03 00:00:00.000000000 Z
11
+ date: 2026-03-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activestorage