bard-attachment_field 0.1.0

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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.nvmrc +1 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +15 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE +21 -0
  9. data/README.md +39 -0
  10. data/Rakefile +10 -0
  11. data/app/assets/javascripts/input-attachment.js +6021 -0
  12. data/app/controllers/bard/attachment_field/blobs_controller.rb +11 -0
  13. data/bard-attachment_field.gemspec +56 -0
  14. data/config/cucumber.yml +1 -0
  15. data/config/routes.rb +6 -0
  16. data/gemfiles/rails_7.1.gemfile +7 -0
  17. data/gemfiles/rails_7.2.gemfile +7 -0
  18. data/gemfiles/rails_8.0.gemfile +7 -0
  19. data/gemfiles/rails_8.1.gemfile +7 -0
  20. data/input-attachment/.editorconfig +15 -0
  21. data/input-attachment/.github/workflows/test.yml +21 -0
  22. data/input-attachment/.gitignore +27 -0
  23. data/input-attachment/.prettierrc.json +13 -0
  24. data/input-attachment/CLAUDE.md +63 -0
  25. data/input-attachment/LICENSE +21 -0
  26. data/input-attachment/README.md +288 -0
  27. data/input-attachment/bin/log +2 -0
  28. data/input-attachment/bin/server +1 -0
  29. data/input-attachment/bin/setup +4 -0
  30. data/input-attachment/bun.lockb +0 -0
  31. data/input-attachment/bundle.js +3 -0
  32. data/input-attachment/jest-setup.js +24 -0
  33. data/input-attachment/package.json +56 -0
  34. data/input-attachment/src/components/attachment-file/accepts.ts +32 -0
  35. data/input-attachment/src/components/attachment-file/attachment-file.css +89 -0
  36. data/input-attachment/src/components/attachment-file/attachment-file.e2e.ts +11 -0
  37. data/input-attachment/src/components/attachment-file/attachment-file.spec.tsx +20 -0
  38. data/input-attachment/src/components/attachment-file/attachment-file.tsx +157 -0
  39. data/input-attachment/src/components/attachment-file/direct-upload-controller.tsx +100 -0
  40. data/input-attachment/src/components/attachment-file/extensions.ts +13 -0
  41. data/input-attachment/src/components/attachment-file/max.ts +46 -0
  42. data/input-attachment/src/components/attachment-file/readme.md +55 -0
  43. data/input-attachment/src/components/attachment-preview/attachment-preview.css +8 -0
  44. data/input-attachment/src/components/attachment-preview/attachment-preview.e2e.ts +11 -0
  45. data/input-attachment/src/components/attachment-preview/attachment-preview.spec.tsx +19 -0
  46. data/input-attachment/src/components/attachment-preview/attachment-preview.tsx +42 -0
  47. data/input-attachment/src/components/attachment-preview/readme.md +31 -0
  48. data/input-attachment/src/components/input-attachment/form-controller.tsx +146 -0
  49. data/input-attachment/src/components/input-attachment/input-attachment.css +100 -0
  50. data/input-attachment/src/components/input-attachment/input-attachment.e2e.ts +11 -0
  51. data/input-attachment/src/components/input-attachment/input-attachment.spec.tsx +37 -0
  52. data/input-attachment/src/components/input-attachment/input-attachment.tsx +353 -0
  53. data/input-attachment/src/components/input-attachment/readme.md +45 -0
  54. data/input-attachment/src/components.d.ts +175 -0
  55. data/input-attachment/src/global.d.ts +3 -0
  56. data/input-attachment/src/images/example.jpg +0 -0
  57. data/input-attachment/src/index.html +36 -0
  58. data/input-attachment/src/index.ts +1 -0
  59. data/input-attachment/src/utils/utils.spec.ts +19 -0
  60. data/input-attachment/src/utils/utils.ts +14 -0
  61. data/input-attachment/stencil.config.ts +43 -0
  62. data/input-attachment/test-mocks/file-drop.cjs +7 -0
  63. data/input-attachment/test-mocks/progress-bar.cjs +9 -0
  64. data/input-attachment/tsconfig.json +32 -0
  65. data/lib/bard/attachment_field/cucumber.rb +277 -0
  66. data/lib/bard/attachment_field/field.rb +33 -0
  67. data/lib/bard/attachment_field/form_builder.rb +12 -0
  68. data/lib/bard/attachment_field/version.rb +7 -0
  69. data/lib/bard/attachment_field.rb +20 -0
  70. data/lib/bard-attachment_field.rb +1 -0
  71. metadata +409 -0
@@ -0,0 +1,175 @@
1
+ /* eslint-disable */
2
+ /* tslint:disable */
3
+ /**
4
+ * This is an autogenerated file created by the Stencil compiler.
5
+ * It contains typing information for all components that exist in this project.
6
+ */
7
+ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
8
+ export namespace Components {
9
+ interface AttachmentFile {
10
+ "accepts": string;
11
+ "filename": string;
12
+ "filetype": string;
13
+ "max": number;
14
+ "name": string;
15
+ /**
16
+ * @default 100
17
+ */
18
+ "percent": number;
19
+ /**
20
+ * @default true
21
+ */
22
+ "preview": boolean;
23
+ "size": number;
24
+ "src": string;
25
+ /**
26
+ * @default "complete"
27
+ */
28
+ "state": string;
29
+ "url": string;
30
+ "validationMessage": string;
31
+ /**
32
+ * @default ""
33
+ */
34
+ "value": string;
35
+ }
36
+ interface AttachmentPreview {
37
+ "filetype": string;
38
+ "src": string;
39
+ }
40
+ interface InputAttachment {
41
+ "accepts": string;
42
+ "directupload": string;
43
+ /**
44
+ * @default false
45
+ */
46
+ "disabled": boolean;
47
+ "max": number;
48
+ /**
49
+ * @default false
50
+ */
51
+ "multiple": boolean;
52
+ "name": string;
53
+ /**
54
+ * @default true
55
+ */
56
+ "preview": boolean;
57
+ /**
58
+ * @default false
59
+ */
60
+ "required": boolean;
61
+ }
62
+ }
63
+ export interface AttachmentFileCustomEvent<T> extends CustomEvent<T> {
64
+ detail: T;
65
+ target: HTMLAttachmentFileElement;
66
+ }
67
+ declare global {
68
+ interface HTMLAttachmentFileElementEventMap {
69
+ "attachment-file:remove": any;
70
+ "attachment-file:validation": any;
71
+ }
72
+ interface HTMLAttachmentFileElement extends Components.AttachmentFile, HTMLStencilElement {
73
+ addEventListener<K extends keyof HTMLAttachmentFileElementEventMap>(type: K, listener: (this: HTMLAttachmentFileElement, ev: AttachmentFileCustomEvent<HTMLAttachmentFileElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
74
+ addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
75
+ addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
76
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
77
+ removeEventListener<K extends keyof HTMLAttachmentFileElementEventMap>(type: K, listener: (this: HTMLAttachmentFileElement, ev: AttachmentFileCustomEvent<HTMLAttachmentFileElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
78
+ removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
79
+ removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
80
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
81
+ }
82
+ var HTMLAttachmentFileElement: {
83
+ prototype: HTMLAttachmentFileElement;
84
+ new (): HTMLAttachmentFileElement;
85
+ };
86
+ interface HTMLAttachmentPreviewElement extends Components.AttachmentPreview, HTMLStencilElement {
87
+ }
88
+ var HTMLAttachmentPreviewElement: {
89
+ prototype: HTMLAttachmentPreviewElement;
90
+ new (): HTMLAttachmentPreviewElement;
91
+ };
92
+ interface HTMLInputAttachmentElement extends Components.InputAttachment, HTMLStencilElement {
93
+ }
94
+ var HTMLInputAttachmentElement: {
95
+ prototype: HTMLInputAttachmentElement;
96
+ new (): HTMLInputAttachmentElement;
97
+ };
98
+ interface HTMLElementTagNameMap {
99
+ "attachment-file": HTMLAttachmentFileElement;
100
+ "attachment-preview": HTMLAttachmentPreviewElement;
101
+ "input-attachment": HTMLInputAttachmentElement;
102
+ }
103
+ }
104
+ declare namespace LocalJSX {
105
+ interface AttachmentFile {
106
+ "accepts"?: string;
107
+ "filename"?: string;
108
+ "filetype"?: string;
109
+ "max"?: number;
110
+ "name"?: string;
111
+ "onAttachment-file:remove"?: (event: AttachmentFileCustomEvent<any>) => void;
112
+ "onAttachment-file:validation"?: (event: AttachmentFileCustomEvent<any>) => void;
113
+ /**
114
+ * @default 100
115
+ */
116
+ "percent"?: number;
117
+ /**
118
+ * @default true
119
+ */
120
+ "preview"?: boolean;
121
+ "size"?: number;
122
+ "src"?: string;
123
+ /**
124
+ * @default "complete"
125
+ */
126
+ "state"?: string;
127
+ "url"?: string;
128
+ "validationMessage"?: string;
129
+ /**
130
+ * @default ""
131
+ */
132
+ "value"?: string;
133
+ }
134
+ interface AttachmentPreview {
135
+ "filetype"?: string;
136
+ "src"?: string;
137
+ }
138
+ interface InputAttachment {
139
+ "accepts"?: string;
140
+ "directupload"?: string;
141
+ /**
142
+ * @default false
143
+ */
144
+ "disabled"?: boolean;
145
+ "max"?: number;
146
+ /**
147
+ * @default false
148
+ */
149
+ "multiple"?: boolean;
150
+ "name"?: string;
151
+ /**
152
+ * @default true
153
+ */
154
+ "preview"?: boolean;
155
+ /**
156
+ * @default false
157
+ */
158
+ "required"?: boolean;
159
+ }
160
+ interface IntrinsicElements {
161
+ "attachment-file": AttachmentFile;
162
+ "attachment-preview": AttachmentPreview;
163
+ "input-attachment": InputAttachment;
164
+ }
165
+ }
166
+ export { LocalJSX as JSX };
167
+ declare module "@stencil/core" {
168
+ export namespace JSX {
169
+ interface IntrinsicElements {
170
+ "attachment-file": LocalJSX.AttachmentFile & JSXBase.HTMLAttributes<HTMLAttachmentFileElement>;
171
+ "attachment-preview": LocalJSX.AttachmentPreview & JSXBase.HTMLAttributes<HTMLAttachmentPreviewElement>;
172
+ "input-attachment": LocalJSX.InputAttachment & JSXBase.HTMLAttributes<HTMLInputAttachmentElement>;
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,3 @@
1
+ declare module '@rails/activestorage'
2
+ declare module '@rails/request.js'
3
+ declare module 'rails-request-json'
@@ -0,0 +1,36 @@
1
+ <!DOCTYPE html>
2
+ <html dir="ltr" lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
6
+ <title>&lt;input-attachment&gt; and friends</title>
7
+ <script type="importmap">
8
+ { "imports": {
9
+ "@stencil/core": "https://ga.jspm.io/npm:@stencil/core@4.8.0/internal/stencil-core/index.js",
10
+ "@stencil/core/internal/app-data": "https://ga.jspm.io/npm:@stencil/core@4.8.0/internal/app-data/index.js",
11
+ "@stencil/core/internal/client": "https://ga.jspm.io/npm:@stencil/core@4.8.0/internal/client/index.js"
12
+ } }
13
+ </script>
14
+ <script type="module" src="./build/input-attachment.esm.js"></script>
15
+ </head>
16
+ <body style="background: #f0f0f0;">
17
+ <fieldset>
18
+ <legend>&lt;input-attachment&gt;</legend>
19
+ <form>
20
+ <input-attachment name="image" id="pep" directupload="http://smh.localhost/rails/active_storage/direct_uploads" accepts="image,video">
21
+ <attachment-file name="image" src="images/example.jpg" filename="example1.jpg" size="36287" max="1000000"></attachment-file>
22
+ </input-attachment>
23
+ </form>
24
+ </fieldset>
25
+
26
+ <fieldset>
27
+ <legend>&lt;attachment-file&gt;</legend>
28
+ <attachment-file name="image" src="images/example.jpg" filename="example.jpg" size="36287" filetype="image" max="1000000"></attachment-file>
29
+ </fieldset>
30
+
31
+ <fieldset>
32
+ <legend>&lt;attachment-preview&gt;</legend>
33
+ <attachment-preview src="images/example.jpg" filetype="image"></attachment-preview>
34
+ </fieldset>
35
+ </body>
36
+ </html>
@@ -0,0 +1 @@
1
+ export * from './components';
@@ -0,0 +1,19 @@
1
+ import { html, arrayRemove } from './utils';
2
+
3
+ describe('utils', () => {
4
+ describe('html', () => {
5
+ it('creates DOM elements from HTML string', () => {
6
+ const element = html('<div>test</div>');
7
+ expect(element.tagName).toBe('DIV');
8
+ expect(element.textContent).toBe('test');
9
+ });
10
+ });
11
+
12
+ describe('arrayRemove', () => {
13
+ it('removes first occurrence of item from array', () => {
14
+ const arr = [1, 2, 3, 4, 3];
15
+ arrayRemove(arr, 3);
16
+ expect(arr).toEqual([1, 2, 4, 3]);
17
+ });
18
+ });
19
+ });
@@ -0,0 +1,14 @@
1
+ function html(html) {
2
+ const el = document.createElement("div")
3
+ el.innerHTML = html
4
+ return el.children[0]
5
+ }
6
+
7
+ function arrayRemove(arr, e) {
8
+ const index = arr.findIndex(x => x === e)
9
+ if(index !== -1) {
10
+ arr.splice(index, 1)
11
+ }
12
+ }
13
+
14
+ export { html, arrayRemove }
@@ -0,0 +1,43 @@
1
+ import { Config } from '@stencil/core';
2
+
3
+ export const config: Config = {
4
+ namespace: 'input-attachment',
5
+ outputTargets: [
6
+ {
7
+ type: 'dist-custom-elements',
8
+ dir: 'dist/components',
9
+ customElementsExportBehavior: 'bundle',
10
+ isPrimaryPackageOutputTarget: true,
11
+ },
12
+ {
13
+ type: 'www',
14
+ dir: 'www',
15
+ serviceWorker: null,
16
+ copy: [
17
+ { src: 'images', dest: 'images' }
18
+ ]
19
+ },
20
+ {
21
+ type: 'docs-readme',
22
+ },
23
+ ],
24
+ devServer: {
25
+ reloadStrategy: 'pageReload',
26
+ root: 'www',
27
+ },
28
+ testing: {
29
+ browserHeadless: "new",
30
+ useESModules: true,
31
+ moduleNameMapper: {
32
+ "^@botandrose/progress-bar$": "<rootDir>/test-mocks/progress-bar.cjs",
33
+ "^@botandrose/file-drop$": "<rootDir>/test-mocks/file-drop.cjs"
34
+ },
35
+ transformIgnorePatterns: [
36
+ "node_modules/(?!(rails-request-json|@botandrose/progress-bar|@botandrose/file-drop|@rails/request\.js))"
37
+ ],
38
+ testPathIgnorePatterns: ["/node_modules/", "/dist/"],
39
+ setupFilesAfterEnv: ["<rootDir>/jest-setup.js"],
40
+ browserArgs: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
41
+ },
42
+ validatePrimaryPackageOutputTarget: true,
43
+ };
@@ -0,0 +1,7 @@
1
+ // Mock for @botandrose/file-drop in Jest tests (CommonJS)
2
+ module.exports = {};
3
+ if (typeof customElements !== 'undefined' && !customElements.get('file-drop')) {
4
+ customElements.define('file-drop', class extends HTMLElement {
5
+ connectedCallback() {}
6
+ });
7
+ }
@@ -0,0 +1,9 @@
1
+ // Mock for @botandrose/progress-bar in Jest tests (CommonJS)
2
+ module.exports = {};
3
+ if (typeof customElements !== 'undefined' && !customElements.get('progress-bar')) {
4
+ customElements.define('progress-bar', class extends HTMLElement {
5
+ static get observedAttributes() { return ['value', 'max']; }
6
+ connectedCallback() {}
7
+ attributeChangedCallback() {}
8
+ });
9
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowSyntheticDefaultImports": true,
4
+ "allowUnreachableCode": false,
5
+ "declaration": false,
6
+ "esModuleInterop": true,
7
+ "experimentalDecorators": true,
8
+ "lib": [
9
+ "dom",
10
+ "esnext"
11
+ ],
12
+ "module": "esnext",
13
+ "moduleResolution": "node",
14
+ "target": "esnext",
15
+ "skipLibCheck": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "jsx": "react",
19
+ "jsxFactory": "h",
20
+ "jsxFragmentFactory": "Fragment",
21
+ "types": []
22
+ },
23
+ "include": [
24
+ "src"
25
+ ],
26
+ "exclude": [
27
+ "**/*.spec.ts",
28
+ "**/*.spec.tsx",
29
+ "**/*.e2e.ts",
30
+ "**/*.e2e.tsx"
31
+ ]
32
+ }
@@ -0,0 +1,277 @@
1
+ # Cucumber integration for bard-attachment_field
2
+ #
3
+ # Usage: require "bard/attachment_field/cucumber" in your features/support/env.rb
4
+ #
5
+ # Provides step definitions and helpers for testing attachment fields.
6
+ #
7
+ # Note: CDP's DOM.setFileInputFiles silently fails for file inputs inside shadow DOM.
8
+ # This integration uses a workaround that creates a temporary regular DOM input to
9
+ # receive files via CDP, then transfers them to the component via its addFiles() method.
10
+
11
+ require "active_support/core_ext/module/attribute_accessors"
12
+ require "chop"
13
+ require "rspec/expectations"
14
+
15
+ module Bard::AttachmentField::TestHelper
16
+ mattr_accessor :fixtures_path do
17
+ -> { Rails.root.join("features/support/fixtures") }
18
+ end
19
+
20
+ module_function
21
+
22
+ def resolve_fixture_path(filename)
23
+ File.expand_path(filename, fixtures_path.call)
24
+ end
25
+
26
+ def find_field(session, label)
27
+ label_element = session.find("label", text: /^#{Regexp.escape(label)}$/)
28
+ element_id = label_element[:for]
29
+ session.find("input-attachment##{element_id}")
30
+ end
31
+
32
+ # CDP's setFileInputFiles fails silently for file inputs inside shadow DOM,
33
+ # so we use a temp regular DOM input and transfer files via JavaScript.
34
+ def attach_files(session, element_id, file_paths)
35
+ session.execute_script("document.body.insertAdjacentHTML('beforeend', '<input type=\"file\" id=\"_cdp_file_helper\" multiple style=\"display:none\">')")
36
+
37
+ temp_input = session.find("#_cdp_file_helper", visible: :all)
38
+ temp_input.native.node.select_file(file_paths)
39
+
40
+ session.execute_script(<<~JS)
41
+ (() => {
42
+ const temp = document.getElementById('_cdp_file_helper');
43
+ const host = document.getElementById('#{element_id}');
44
+ host.addFiles(temp.files);
45
+ temp.remove();
46
+ })()
47
+ JS
48
+ end
49
+
50
+ 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'))
54
+ JS
55
+ raise "Uploads not complete (states=#{states})" unless states.all? { |s| s == "complete" || s == "error" }
56
+ end
57
+ end
58
+
59
+ def get_files(field)
60
+ field.all("attachment-file").map { |e| e[:filename] }
61
+ end
62
+
63
+ def validation_messages(session, element)
64
+ messages = []
65
+ messages << element.evaluate_script("this.validationMessage")
66
+ # Get validation errors from attachment-file elements' shadow DOM
67
+ child_errors = session.evaluate_script(<<~JS, element[:id])
68
+ ((elementId) => {
69
+ const host = document.getElementById(elementId);
70
+ const files = host.querySelectorAll('attachment-file');
71
+ return Array.from(files).map(f => {
72
+ const errorEl = f.shadowRoot?.querySelector('.validation-error');
73
+ return errorEl?.textContent || '';
74
+ }).filter(e => e);
75
+ })(arguments[0])
76
+ JS
77
+ messages.concat(child_errors)
78
+ messages.select { |m| m && !m.empty? }
79
+ end
80
+ end
81
+
82
+ # Chop integration for form diffing and filling
83
+ class Chop::Form::AttachmentField < Chop::Form::Field
84
+ def self.css_selector
85
+ "input-attachment"
86
+ end
87
+
88
+ def matches?
89
+ field.tag_name == "input-attachment"
90
+ end
91
+
92
+ def get_value
93
+ Bard::AttachmentField::TestHelper.get_files(field)
94
+ end
95
+
96
+ def diff_value
97
+ get_value.join(", ")
98
+ end
99
+
100
+ def set_value
101
+ filenames = if field[:multiple]
102
+ value.to_s.split(", ").map(&:strip)
103
+ else
104
+ [value.to_s.strip]
105
+ end
106
+ filenames.reject(&:empty?)
107
+ end
108
+
109
+ def fill_in!
110
+ return if set_value.empty?
111
+
112
+ file_paths = set_value.map do |filename|
113
+ ::File.expand_path(::File.join(path, filename)).tap do |full_path|
114
+ ::File.open(full_path) {} # raise Errno::ENOENT if file doesn't exist
115
+ end
116
+ end
117
+
118
+ Bard::AttachmentField::TestHelper.attach_files(session, field[:id], file_paths)
119
+ Bard::AttachmentField::TestHelper.wait_for_upload(session, field[:id])
120
+ end
121
+ end
122
+
123
+ # Step definitions
124
+
125
+ When "I attach the file {string} to {string}" do |path, field|
126
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
127
+ file_path_full = Bard::AttachmentField::TestHelper.resolve_fixture_path(path)
128
+
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")
133
+ end
134
+
135
+ Bard::AttachmentField::TestHelper.wait_for_upload(page, element[:id])
136
+ end
137
+
138
+ When "I attach the following files to {string}:" do |field, table|
139
+ files = table.raw.map(&:first).map { |filename| Bard::AttachmentField::TestHelper.resolve_fixture_path(filename) }
140
+
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)
147
+ end
148
+
149
+ Bard::AttachmentField::TestHelper.wait_for_upload(page, element[:id], timeout: 60)
150
+ end
151
+
152
+ When "I remove the file from {string}" do |field|
153
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
154
+ element_id = element[:id]
155
+ # Use JavaScript to click the remove link since Cuprite has issues with shadow DOM paths
156
+ page.execute_script(<<~JS, element_id)
157
+ ((elementId) => {
158
+ const host = document.getElementById(elementId);
159
+ const attachmentFile = host.querySelector('attachment-file');
160
+ const removeLink = attachmentFile.shadowRoot.querySelector('a.remove-media');
161
+ removeLink.click();
162
+ })(arguments[0])
163
+ 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
169
+ end
170
+
171
+ When "I remove {string} from {string}" do |filename, field|
172
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
173
+ element_id = element[:id]
174
+ # Use JavaScript to click the remove link since Cuprite has issues with shadow DOM paths
175
+ page.execute_script(<<~JS, element_id, filename)
176
+ ((elementId, filename) => {
177
+ const host = document.getElementById(elementId);
178
+ const attachmentFile = host.querySelector(`attachment-file[filename='${filename}']`);
179
+ const removeLink = attachmentFile.shadowRoot.querySelector('a.remove-media');
180
+ removeLink.click();
181
+ })(arguments[0], arguments[1])
182
+ 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
188
+ end
189
+
190
+ When "I drag the file {string} onto the {string} attachment field" do |path, field|
191
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
192
+ file_path_full = Bard::AttachmentField::TestHelper.resolve_fixture_path(path)
193
+
194
+ # Create temp input to get File objects via CDP, then dispatch drop event on file-drop
195
+ page.execute_script("document.body.insertAdjacentHTML('beforeend', '<input type=\"file\" id=\"_cdp_file_helper\" multiple style=\"display:none\">')")
196
+ temp_input = page.find("#_cdp_file_helper", visible: :all)
197
+ temp_input.native.node.select_file([file_path_full])
198
+
199
+ page.execute_script(<<~JS, element[:id])
200
+ ((elementId) => {
201
+ const temp = document.getElementById('_cdp_file_helper');
202
+ const host = document.getElementById(elementId);
203
+ const fileDrop = host.shadowRoot.querySelector('file-drop');
204
+
205
+ const dt = new DataTransfer();
206
+ Array.from(temp.files).forEach(f => dt.items.add(f));
207
+
208
+ const dropEvent = new DragEvent('drop', {
209
+ bubbles: true,
210
+ cancelable: true,
211
+ dataTransfer: dt
212
+ });
213
+ fileDrop.dispatchEvent(dropEvent);
214
+ temp.remove();
215
+ })(arguments[0])
216
+ JS
217
+
218
+ page.document.synchronize(15, errors: [Capybara::ElementNotFound]) do
219
+ element.find("attachment-file")
220
+ end
221
+
222
+ Bard::AttachmentField::TestHelper.wait_for_upload(page, element[:id])
223
+ end
224
+
225
+ Then "I should see a preview of {string} for the {string} field" do |filename, field|
226
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
227
+ expect(element.find("attachment-file")[:filename]).to eq(filename)
228
+ end
229
+
230
+ When "I follow the {string} download link for {string}" do |filename, field|
231
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
232
+ # Click the download link inside attachment-file's shadow DOM
233
+ page.execute_script(<<~JS, element[:id], filename)
234
+ ((elementId, filename) => {
235
+ const host = document.getElementById(elementId);
236
+ const attachmentFile = host.querySelector(`attachment-file[filename='${filename}']`) ||
237
+ host.querySelector('attachment-file');
238
+ const downloadLink = attachmentFile.shadowRoot.querySelector('a.download-link');
239
+ downloadLink.click();
240
+ })(arguments[0], arguments[1])
241
+ JS
242
+ end
243
+
244
+ Then "the {string} attachment field should be disabled" do |field|
245
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
246
+
247
+ is_disabled = page.evaluate_script(<<~JS)
248
+ (function() {
249
+ const element = document.getElementById('#{element[:id]}');
250
+ if (element.hasAttribute('disabled')) return true;
251
+ if (element.closest('fieldset[disabled]')) return true;
252
+ const fileInput = element.shadowRoot?.querySelector('input[type="file"]');
253
+ return fileInput?.disabled || false;
254
+ })()
255
+ JS
256
+
257
+ expect(is_disabled).to be true
258
+ end
259
+
260
+ Then "the {string} attachment field should have a validation error containing {string}" do |field, message|
261
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
262
+ messages = Bard::AttachmentField::TestHelper.validation_messages(page, element)
263
+ expect(messages).to include(a_string_including(message))
264
+ end
265
+
266
+ Then "the {string} attachment field should have no validation errors" do |field|
267
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
268
+ messages = Bard::AttachmentField::TestHelper.validation_messages(page, element)
269
+ expect(messages).to be_empty
270
+ end
271
+
272
+ Then "I should not be able to attach a file to {string}" do |field|
273
+ element = Bard::AttachmentField::TestHelper.find_field(page, field)
274
+ shadow_root = element.shadow_root
275
+ file_input = shadow_root.find("input[type='file']", visible: :all)
276
+ expect(file_input).to be_disabled
277
+ end
@@ -0,0 +1,33 @@
1
+ module Bard
2
+ module AttachmentField
3
+ class Field < ActionView::Helpers::Tags::TextField
4
+ def render &block
5
+ options = @options.stringify_keys.reverse_merge({
6
+ "directupload" => "/rails/active_storage/direct_uploads",
7
+ "preview" => true,
8
+ })
9
+ add_default_name_and_id(options)
10
+
11
+ content_tag("input-attachment", options) do
12
+ next block.call(options) if block
13
+ Array(object.try(@method_name)).map do |attachment|
14
+ content_tag("attachment-file", nil, {
15
+ name: options["name"],
16
+ src: blob_path(attachment),
17
+ filename: attachment.filename,
18
+ value: attachment.signed_id,
19
+ preview: options["preview"],
20
+ })
21
+ end.join("\n").html_safe
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def blob_path(attachment)
28
+ "/rails/active_storage/blobs/redirect/#{attachment.signed_id}/#{attachment.filename}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+
@@ -0,0 +1,12 @@
1
+ require_relative "field"
2
+
3
+ module Bard
4
+ module AttachmentField
5
+ module FormBuilder
6
+ def attachment_field method, options={}, &block
7
+ self.multipart = true
8
+ Field.new(@object_name, method, @template, objectify_options(options)).render(&block)
9
+ end
10
+ end
11
+ end
12
+ end