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.
- checksums.yaml +7 -0
- data/.nvmrc +1 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +39 -0
- data/Rakefile +10 -0
- data/app/assets/javascripts/input-attachment.js +6021 -0
- data/app/controllers/bard/attachment_field/blobs_controller.rb +11 -0
- data/bard-attachment_field.gemspec +56 -0
- data/config/cucumber.yml +1 -0
- data/config/routes.rb +6 -0
- data/gemfiles/rails_7.1.gemfile +7 -0
- data/gemfiles/rails_7.2.gemfile +7 -0
- data/gemfiles/rails_8.0.gemfile +7 -0
- data/gemfiles/rails_8.1.gemfile +7 -0
- data/input-attachment/.editorconfig +15 -0
- data/input-attachment/.github/workflows/test.yml +21 -0
- data/input-attachment/.gitignore +27 -0
- data/input-attachment/.prettierrc.json +13 -0
- data/input-attachment/CLAUDE.md +63 -0
- data/input-attachment/LICENSE +21 -0
- data/input-attachment/README.md +288 -0
- data/input-attachment/bin/log +2 -0
- data/input-attachment/bin/server +1 -0
- data/input-attachment/bin/setup +4 -0
- data/input-attachment/bun.lockb +0 -0
- data/input-attachment/bundle.js +3 -0
- data/input-attachment/jest-setup.js +24 -0
- data/input-attachment/package.json +56 -0
- data/input-attachment/src/components/attachment-file/accepts.ts +32 -0
- data/input-attachment/src/components/attachment-file/attachment-file.css +89 -0
- data/input-attachment/src/components/attachment-file/attachment-file.e2e.ts +11 -0
- data/input-attachment/src/components/attachment-file/attachment-file.spec.tsx +20 -0
- data/input-attachment/src/components/attachment-file/attachment-file.tsx +157 -0
- data/input-attachment/src/components/attachment-file/direct-upload-controller.tsx +100 -0
- data/input-attachment/src/components/attachment-file/extensions.ts +13 -0
- data/input-attachment/src/components/attachment-file/max.ts +46 -0
- data/input-attachment/src/components/attachment-file/readme.md +55 -0
- data/input-attachment/src/components/attachment-preview/attachment-preview.css +8 -0
- data/input-attachment/src/components/attachment-preview/attachment-preview.e2e.ts +11 -0
- data/input-attachment/src/components/attachment-preview/attachment-preview.spec.tsx +19 -0
- data/input-attachment/src/components/attachment-preview/attachment-preview.tsx +42 -0
- data/input-attachment/src/components/attachment-preview/readme.md +31 -0
- data/input-attachment/src/components/input-attachment/form-controller.tsx +146 -0
- data/input-attachment/src/components/input-attachment/input-attachment.css +100 -0
- data/input-attachment/src/components/input-attachment/input-attachment.e2e.ts +11 -0
- data/input-attachment/src/components/input-attachment/input-attachment.spec.tsx +37 -0
- data/input-attachment/src/components/input-attachment/input-attachment.tsx +353 -0
- data/input-attachment/src/components/input-attachment/readme.md +45 -0
- data/input-attachment/src/components.d.ts +175 -0
- data/input-attachment/src/global.d.ts +3 -0
- data/input-attachment/src/images/example.jpg +0 -0
- data/input-attachment/src/index.html +36 -0
- data/input-attachment/src/index.ts +1 -0
- data/input-attachment/src/utils/utils.spec.ts +19 -0
- data/input-attachment/src/utils/utils.ts +14 -0
- data/input-attachment/stencil.config.ts +43 -0
- data/input-attachment/test-mocks/file-drop.cjs +7 -0
- data/input-attachment/test-mocks/progress-bar.cjs +9 -0
- data/input-attachment/tsconfig.json +32 -0
- data/lib/bard/attachment_field/cucumber.rb +277 -0
- data/lib/bard/attachment_field/field.rb +33 -0
- data/lib/bard/attachment_field/form_builder.rb +12 -0
- data/lib/bard/attachment_field/version.rb +7 -0
- data/lib/bard/attachment_field.rb +20 -0
- data/lib/bard-attachment_field.rb +1 -0
- metadata +409 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import DirectUploadController from "../attachment-file/direct-upload-controller"
|
|
2
|
+
|
|
3
|
+
export default class FormController {
|
|
4
|
+
static instance(form) {
|
|
5
|
+
return form.inputAttachmentFormController ||= new FormController(form)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
progressContainerTarget: HTMLElement
|
|
9
|
+
dialog: HTMLDialogElement
|
|
10
|
+
|
|
11
|
+
element: HTMLFormElement
|
|
12
|
+
progressTargetMap: {}
|
|
13
|
+
controllers: Array<DirectUploadController>
|
|
14
|
+
submitted: boolean
|
|
15
|
+
processing: boolean
|
|
16
|
+
errors: boolean
|
|
17
|
+
|
|
18
|
+
constructor(form) {
|
|
19
|
+
this.element = form
|
|
20
|
+
this.progressTargetMap = {}
|
|
21
|
+
this.controllers = []
|
|
22
|
+
this.submitted = false
|
|
23
|
+
this.processing = false
|
|
24
|
+
this.errors = false
|
|
25
|
+
|
|
26
|
+
this.element.insertAdjacentHTML("beforeend",
|
|
27
|
+
`<dialog id="form-controller-dialog">
|
|
28
|
+
<div class="direct-upload-wrapper">
|
|
29
|
+
<div class="direct-upload-content">
|
|
30
|
+
<h3>Uploading your media</h3>
|
|
31
|
+
<div id="progress-container"></div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</dialog>`)
|
|
35
|
+
|
|
36
|
+
this.dialog = this.element.querySelector("#form-controller-dialog")
|
|
37
|
+
this.progressContainerTarget = this.dialog.querySelector("#progress-container")
|
|
38
|
+
|
|
39
|
+
if(this.element.dataset.remote !== "true" && (this.element.dataset.turbo == "false" || !(window as any).Turbo?.session?.enabled)) {
|
|
40
|
+
this.element.addEventListener("submit", event => this.submit(event))
|
|
41
|
+
}
|
|
42
|
+
window.addEventListener("beforeunload", event => this.beforeUnload(event))
|
|
43
|
+
|
|
44
|
+
this.element.addEventListener("direct-upload:initialize", event => this.init(event))
|
|
45
|
+
this.element.addEventListener("direct-upload:start", event => this.start(event))
|
|
46
|
+
this.element.addEventListener("direct-upload:progress", event => this.progress(event))
|
|
47
|
+
this.element.addEventListener("direct-upload:error", event => this.error(event))
|
|
48
|
+
this.element.addEventListener("direct-upload:end", event => this.end(event))
|
|
49
|
+
|
|
50
|
+
this.element.addEventListener("attachment-file:remove", event => this.removeUploadedFile(event))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeUnload(event) {
|
|
54
|
+
if(this.processing) {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
return (event.returnValue = "")
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
submit(event) {
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
this.submitted = true
|
|
63
|
+
this.startNextController()
|
|
64
|
+
if(this.processing) {
|
|
65
|
+
this.dialog.showModal()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
startNextController() {
|
|
70
|
+
if(this.processing) return
|
|
71
|
+
|
|
72
|
+
const controller = this.controllers.shift()
|
|
73
|
+
if(controller) {
|
|
74
|
+
this.processing = true
|
|
75
|
+
this.setInputAttachmentsDisabled(true)
|
|
76
|
+
controller.start(error => {
|
|
77
|
+
if(error) {
|
|
78
|
+
this.setInputAttachmentsDisabled(false)
|
|
79
|
+
}
|
|
80
|
+
this.processing = false
|
|
81
|
+
this.startNextController()
|
|
82
|
+
})
|
|
83
|
+
} else {
|
|
84
|
+
this.submitForm()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
submitForm() {
|
|
89
|
+
if(this.submitted) {
|
|
90
|
+
this.setInputAttachmentsDisabled(true)
|
|
91
|
+
window.setTimeout(() => { // allow other async tasks to complete
|
|
92
|
+
this.element.submit()
|
|
93
|
+
}, 10)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setInputAttachmentsDisabled(disabled: boolean) {
|
|
98
|
+
Array.from(this.element.querySelectorAll("input-attachment"))
|
|
99
|
+
.forEach((el: any) => {
|
|
100
|
+
el.disabled = disabled
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
init(event) {
|
|
105
|
+
const { id, file, controller } = event.detail
|
|
106
|
+
|
|
107
|
+
this.progressContainerTarget.insertAdjacentHTML("beforebegin", `
|
|
108
|
+
<progress-bar id="direct-upload-${id}" class="direct-upload--pending">${file?.name || 'Uploading...'}</progress-bar>
|
|
109
|
+
`)
|
|
110
|
+
const progressTarget = document.getElementById(`direct-upload-${id}`)
|
|
111
|
+
this.progressTargetMap[id] = progressTarget
|
|
112
|
+
|
|
113
|
+
this.controllers.push(controller)
|
|
114
|
+
this.startNextController()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
start(event) {
|
|
118
|
+
this.progressTargetMap[event.detail.id].classList.remove("direct-upload--pending")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
progress(event) {
|
|
122
|
+
const { id, progress } = event.detail
|
|
123
|
+
this.progressTargetMap[id].percent = progress
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
error(event) {
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
const { id, error } = event.detail
|
|
129
|
+
const target = this.progressTargetMap[id]
|
|
130
|
+
target.classList.add("direct-upload--error")
|
|
131
|
+
target.title = error
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
end(event) {
|
|
135
|
+
this.progressTargetMap[event.detail.id].classList.add("direct-upload--complete")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
removeUploadedFile(event) {
|
|
139
|
+
const uploadedFile = event.detail
|
|
140
|
+
const id = uploadedFile.controller?.directUpload?.id
|
|
141
|
+
if(id) {
|
|
142
|
+
document.getElementById(`direct-upload-${id}`).remove()
|
|
143
|
+
delete this.progressTargetMap[id]
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
padding: 25px;
|
|
4
|
+
color: var(--input-attachment-text-color, #000);
|
|
5
|
+
font-size: 13px;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
:host *{
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
position: relative;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
file-drop {
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
display: block;
|
|
16
|
+
outline-offset: -10px;
|
|
17
|
+
background: rgba(255,255,255, 0.25);
|
|
18
|
+
padding: 20px;
|
|
19
|
+
text-align: center;
|
|
20
|
+
transition: all 0.15s;
|
|
21
|
+
outline: 2px dashed rgba(0,0,0,0.25);
|
|
22
|
+
color: #444;
|
|
23
|
+
font-size: 14px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
file-drop.-full{
|
|
27
|
+
width: 100%;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
p{
|
|
31
|
+
padding: 10px 20px;
|
|
32
|
+
margin: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.-dragover{
|
|
36
|
+
background: rgba(255,255,255,0.5);
|
|
37
|
+
outline: 2px dashed rgba(0,0,0,0.25);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.media-preview {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-wrap: wrap;
|
|
43
|
+
align-items: flex-start;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// UPLOADER
|
|
48
|
+
|
|
49
|
+
.direct-upload-wrapper{
|
|
50
|
+
position: fixed;
|
|
51
|
+
z-index: 9999;
|
|
52
|
+
top: 0;
|
|
53
|
+
left: 0;
|
|
54
|
+
width: 100vw;
|
|
55
|
+
height: 100vh;
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: center;
|
|
59
|
+
background: rgba(#333, 0.9);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.direct-upload-content{
|
|
63
|
+
display: block;
|
|
64
|
+
background: #fcfcfc;
|
|
65
|
+
padding: 40px 60px 60px;
|
|
66
|
+
border-radius: 3px;
|
|
67
|
+
width: 60vw;
|
|
68
|
+
}
|
|
69
|
+
.direct-upload-content h3{
|
|
70
|
+
border-bottom: 2px solid #1f1f1f;
|
|
71
|
+
margin-bottom: 20px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.separate-upload{
|
|
75
|
+
padding: 0 10px;
|
|
76
|
+
margin-top: 10px;
|
|
77
|
+
font-size: 0.9em;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.direct-upload--pending{
|
|
81
|
+
opacity: 0.6;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.direct-upload--complete {
|
|
85
|
+
opacity: 0.4;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.direct-upload--error{
|
|
89
|
+
border-color: red;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
input[type=file][data-direct-upload-url][disabled]{
|
|
93
|
+
display: none;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
:host.separate-upload{
|
|
97
|
+
padding: 0 10px;
|
|
98
|
+
margin-top: 10px;
|
|
99
|
+
font-size: 0.9em;
|
|
100
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { newE2EPage } from '@stencil/core/testing';
|
|
2
|
+
|
|
3
|
+
describe('input-attachment', () => {
|
|
4
|
+
it('renders', async () => {
|
|
5
|
+
const page = await newE2EPage();
|
|
6
|
+
await page.setContent('<input-attachment></input-attachment>');
|
|
7
|
+
|
|
8
|
+
const element = await page.find('input-attachment');
|
|
9
|
+
expect(element).toHaveClass('hydrated');
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
|
|
2
|
+
// Mock rails-request-json to avoid ES module issues
|
|
3
|
+
jest.mock('rails-request-json', () => ({
|
|
4
|
+
get: jest.fn(() => Promise.resolve({}))
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
// Mock @botandrose/file-drop to avoid ES module issues
|
|
8
|
+
jest.mock('@botandrose/file-drop', () => ({}));
|
|
9
|
+
|
|
10
|
+
import { newSpecPage } from '@stencil/core/testing';
|
|
11
|
+
import { InputAttachment } from './input-attachment';
|
|
12
|
+
|
|
13
|
+
describe('input-attachment', () => {
|
|
14
|
+
|
|
15
|
+
it('renders', async () => {
|
|
16
|
+
const page = await newSpecPage({
|
|
17
|
+
components: [InputAttachment],
|
|
18
|
+
html: `<form><input-attachment></input-attachment></form>`,
|
|
19
|
+
});
|
|
20
|
+
expect(page.root).toEqualHtml(`
|
|
21
|
+
<input-attachment>
|
|
22
|
+
<mock:shadow-root>
|
|
23
|
+
<input type="file" style="opacity: 0.01; width: 1px; height: 1px; z-index: -999;">
|
|
24
|
+
<file-drop>
|
|
25
|
+
<p part="title">
|
|
26
|
+
<strong>Choose file </strong>
|
|
27
|
+
<span>or drag it here.</span>
|
|
28
|
+
</p>
|
|
29
|
+
<div class="media-preview">
|
|
30
|
+
<slot></slot>
|
|
31
|
+
</div>
|
|
32
|
+
</file-drop>
|
|
33
|
+
</mock:shadow-root>
|
|
34
|
+
</input-attachment>
|
|
35
|
+
`);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { Component, Element, Prop, Listen, Host, h, forceUpdate } from '@stencil/core';
|
|
2
|
+
import FormController from "./form-controller"
|
|
3
|
+
import { AttachmentFile } from "../attachment-file/attachment-file"
|
|
4
|
+
import { arrayRemove } from "../../utils/utils"
|
|
5
|
+
import '@botandrose/file-drop'
|
|
6
|
+
import '@botandrose/progress-bar'
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
tag: 'input-attachment',
|
|
10
|
+
styleUrl: 'input-attachment.css',
|
|
11
|
+
shadow: true,
|
|
12
|
+
formAssociated: true,
|
|
13
|
+
})
|
|
14
|
+
export class InputAttachment {
|
|
15
|
+
@Element() el: HTMLElement
|
|
16
|
+
|
|
17
|
+
@Prop() name: string
|
|
18
|
+
@Prop() directupload: string
|
|
19
|
+
@Prop() multiple: boolean = false
|
|
20
|
+
|
|
21
|
+
@Prop() required: boolean = false
|
|
22
|
+
@Prop() accepts: string
|
|
23
|
+
@Prop() max: number
|
|
24
|
+
@Prop() preview: boolean = true
|
|
25
|
+
@Prop() disabled: boolean = false
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
form: HTMLFormElement
|
|
29
|
+
internals: ElementInternals
|
|
30
|
+
fileInput: HTMLInputElement
|
|
31
|
+
_files: Array<any> = []
|
|
32
|
+
|
|
33
|
+
constructor() {
|
|
34
|
+
this.internals = (this.el as any).attachInternals()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
componentWillLoad() {
|
|
38
|
+
this.form = this.internals.form
|
|
39
|
+
if (this.form) {
|
|
40
|
+
this.form.addEventListener("reset", () => this.reset())
|
|
41
|
+
FormController.instance(this.form)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Note: Server-rendered children may not be available yet during componentWillLoad
|
|
45
|
+
// when using lazy-loaded Stencil components. We'll check again in componentDidRender.
|
|
46
|
+
const existingFiles = Array.from(this.el.children).filter(e => e.tagName == "ATTACHMENT-FILE");
|
|
47
|
+
if(existingFiles.length > 0) this.files = existingFiles
|
|
48
|
+
|
|
49
|
+
// Restore from localStorage BEFORE setting initial validity (which triggers saveToLocalStorage)
|
|
50
|
+
this.restoreFromLocalStorage()
|
|
51
|
+
|
|
52
|
+
// Set initial validity state (only if we have files from above)
|
|
53
|
+
if(this.files.length > 0) this.updateFormValue()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
componentDidLoad() {
|
|
57
|
+
// Clear persistence data when form is submitted
|
|
58
|
+
if (this.form) {
|
|
59
|
+
this.form.addEventListener('submit', () => this.clearLocalStorage())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Listen for file input changes directly (JSX onChange doesn't reliably work in shadow DOM)
|
|
63
|
+
this.fileInput?.addEventListener('change', this.handleFileInputChange)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get localStorageKey() {
|
|
67
|
+
if (!this.form || !this.name) return null
|
|
68
|
+
const formId = this.form.id || this.form.action || 'form'
|
|
69
|
+
return `input-attachment:${formId}:${this.name}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
saveToLocalStorage() {
|
|
73
|
+
const key = this.localStorageKey
|
|
74
|
+
if (!key) return
|
|
75
|
+
|
|
76
|
+
const data = this.persistenceData
|
|
77
|
+
if (data.length > 0) {
|
|
78
|
+
localStorage.setItem(key, JSON.stringify(data))
|
|
79
|
+
} else {
|
|
80
|
+
localStorage.removeItem(key)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
restoreFromLocalStorage() {
|
|
85
|
+
const key = this.localStorageKey
|
|
86
|
+
if (!key || this.files.length > 0) return
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const stored = localStorage.getItem(key)
|
|
90
|
+
if (stored) {
|
|
91
|
+
const data = JSON.parse(stored)
|
|
92
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
93
|
+
this.restoreFromPersistence(data)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// Invalid JSON, ignore
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
clearLocalStorage() {
|
|
102
|
+
const key = this.localStorageKey
|
|
103
|
+
if (key) {
|
|
104
|
+
localStorage.removeItem(key)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Methods
|
|
109
|
+
|
|
110
|
+
get files() {
|
|
111
|
+
return this._files
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
set files(val) {
|
|
115
|
+
this._files = val
|
|
116
|
+
if(!this.multiple) this._files = this._files.slice(-1)
|
|
117
|
+
forceUpdate(this.el)
|
|
118
|
+
this.fireChangeEvent()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get value() {
|
|
122
|
+
return this.files.map(e => e.value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
set value(val) {
|
|
126
|
+
const newValue = val || []
|
|
127
|
+
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
|
+
}))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// For form-persistence: store complete attachment data (not just signed_ids)
|
|
137
|
+
get persistenceData() {
|
|
138
|
+
return this.files.map(f => ({
|
|
139
|
+
value: f.value,
|
|
140
|
+
filename: f.filename,
|
|
141
|
+
src: f.src,
|
|
142
|
+
state: f.state,
|
|
143
|
+
percent: f.percent,
|
|
144
|
+
size: f.size,
|
|
145
|
+
filetype: f.filetype,
|
|
146
|
+
}))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
restoreFromPersistence(data: any[]) {
|
|
150
|
+
if (!Array.isArray(data) || data.length === 0) return
|
|
151
|
+
|
|
152
|
+
this.files = data.map(item => {
|
|
153
|
+
const attachmentFile = document.createElement('attachment-file') as any
|
|
154
|
+
attachmentFile.name = this.name
|
|
155
|
+
attachmentFile.preview = this.preview
|
|
156
|
+
attachmentFile.value = item.value
|
|
157
|
+
attachmentFile.filename = item.filename
|
|
158
|
+
attachmentFile.src = item.src
|
|
159
|
+
attachmentFile.state = item.state || 'complete'
|
|
160
|
+
attachmentFile.percent = item.percent || 100
|
|
161
|
+
attachmentFile.size = item.size
|
|
162
|
+
attachmentFile.filetype = item.filetype
|
|
163
|
+
return attachmentFile
|
|
164
|
+
})
|
|
165
|
+
requestAnimationFrame(() => this.componentDidRender())
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
updateFormValue() {
|
|
169
|
+
if (!this.name || !this.internals?.setFormValue) return
|
|
170
|
+
const formData = new FormData()
|
|
171
|
+
const values = this.value.filter(v => v) // filter out empty values
|
|
172
|
+
if (this.multiple) {
|
|
173
|
+
// For has_many_attached: append each signed_id separately
|
|
174
|
+
values.forEach(signedId => formData.append(this.name, signedId))
|
|
175
|
+
// If empty, append empty string so Rails gets an empty array
|
|
176
|
+
if (values.length === 0) formData.append(this.name, '')
|
|
177
|
+
} else {
|
|
178
|
+
// For has_one_attached: set single value (or empty string if none)
|
|
179
|
+
formData.set(this.name, values[0] || '')
|
|
180
|
+
}
|
|
181
|
+
this.internals.setFormValue(formData)
|
|
182
|
+
|
|
183
|
+
// Save to localStorage for persistence across page reloads
|
|
184
|
+
this.saveToLocalStorage()
|
|
185
|
+
|
|
186
|
+
// Update validity state - check for required and child validation errors
|
|
187
|
+
if (this.required && this.files.length === 0) {
|
|
188
|
+
this.internals.setValidity({ valueMissing: true }, "Please select a file.", this.fileInput)
|
|
189
|
+
} else {
|
|
190
|
+
// Check if any child attachment-file has validation errors
|
|
191
|
+
const childErrors = this.files
|
|
192
|
+
.map(f => f.validationError)
|
|
193
|
+
.filter(e => e && e.length > 0)
|
|
194
|
+
if (childErrors.length > 0) {
|
|
195
|
+
this.internals.setValidity({ customError: true }, childErrors[0], this.fileInput)
|
|
196
|
+
} else {
|
|
197
|
+
this.internals.setValidity({})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
reset() {
|
|
203
|
+
this.value = []
|
|
204
|
+
this.clearLocalStorage()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
handleFileInputChange = () => {
|
|
208
|
+
if (!this.fileInput?.files?.length) return
|
|
209
|
+
this.addFiles(this.fileInput.files)
|
|
210
|
+
this.fileInput.value = null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
handleDrop = (event: DragEvent) => {
|
|
214
|
+
event.preventDefault()
|
|
215
|
+
if (this.isDisabled) return
|
|
216
|
+
if (event.dataTransfer?.files?.length) {
|
|
217
|
+
this.addFiles(event.dataTransfer.files)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
@Listen("attachment-file:remove")
|
|
222
|
+
removeUploadedFile(event) {
|
|
223
|
+
arrayRemove(this.files, event.detail)
|
|
224
|
+
this.files = this.files
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@Listen("attachment-file:validation")
|
|
228
|
+
handleChildValidation(_event) {
|
|
229
|
+
// Re-check form validity when a child's validation state changes
|
|
230
|
+
this.updateFormValue()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@Listen("direct-upload:end")
|
|
234
|
+
fireChangeEvent() {
|
|
235
|
+
requestAnimationFrame(() => {
|
|
236
|
+
this.updateFormValue()
|
|
237
|
+
this.el.dispatchEvent(new Event("change", { bubbles: true }))
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Rendering
|
|
242
|
+
|
|
243
|
+
get isDisabled() {
|
|
244
|
+
return this.disabled || !!this.el.closest('fieldset[disabled]')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
render() {
|
|
248
|
+
return (
|
|
249
|
+
<Host>
|
|
250
|
+
<input
|
|
251
|
+
ref={el => this.fileInput = el}
|
|
252
|
+
type="file"
|
|
253
|
+
multiple={this.multiple}
|
|
254
|
+
accept={this.accepts}
|
|
255
|
+
required={this.required && this.files.length === 0}
|
|
256
|
+
disabled={this.isDisabled}
|
|
257
|
+
onChange={() => this.handleFileInputChange()}
|
|
258
|
+
style={{
|
|
259
|
+
opacity: '0.01',
|
|
260
|
+
width: '1px',
|
|
261
|
+
height: '1px',
|
|
262
|
+
zIndex: '-999'
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
<file-drop onClick={() => this.fileInput?.click()} onDrop={this.handleDrop}>
|
|
266
|
+
<p part="title">
|
|
267
|
+
<strong>Choose {this.multiple ? "files" : "file"} </strong>
|
|
268
|
+
<span>or drag {this.multiple ? "them" : "it"} here.</span>
|
|
269
|
+
</p>
|
|
270
|
+
|
|
271
|
+
<div class={`media-preview ${this.multiple ? '-stacked' : ''}`}>
|
|
272
|
+
<slot></slot>
|
|
273
|
+
</div>
|
|
274
|
+
</file-drop>
|
|
275
|
+
</Host>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
componentDidRender() {
|
|
280
|
+
// Check for server-rendered children that we haven't captured yet
|
|
281
|
+
// This handles the case where children aren't available in componentWillLoad
|
|
282
|
+
if (this.files.length === 0) {
|
|
283
|
+
const existingFiles = Array.from(this.el.children).filter(e => e.tagName == "ATTACHMENT-FILE");
|
|
284
|
+
if (existingFiles.length > 0) {
|
|
285
|
+
this._files = existingFiles as any[]
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const wrapper = document.createElement("div")
|
|
290
|
+
this.files.forEach(file => wrapper.appendChild(file))
|
|
291
|
+
|
|
292
|
+
let needsUpdate = false
|
|
293
|
+
if (wrapper.children.length !== this.el.children.length) {
|
|
294
|
+
needsUpdate = true
|
|
295
|
+
} else {
|
|
296
|
+
for (let i = 0; i < wrapper.children.length; i++) {
|
|
297
|
+
if (wrapper.children[i] !== this.el.children[i]) {
|
|
298
|
+
needsUpdate = true
|
|
299
|
+
break
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (needsUpdate) {
|
|
305
|
+
while (this.el.firstChild) {
|
|
306
|
+
this.el.removeChild(this.el.firstChild)
|
|
307
|
+
}
|
|
308
|
+
this.el.appendChild(wrapper)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.updateFormValue()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Test helper - adds files programmatically with proper lifecycle
|
|
315
|
+
addFiles(files: FileList | File[]) {
|
|
316
|
+
const fileArray = Array.from(files)
|
|
317
|
+
fileArray.forEach(file => {
|
|
318
|
+
const attachmentFile = document.createElement('attachment-file') as any
|
|
319
|
+
attachmentFile.name = this.name
|
|
320
|
+
attachmentFile.preview = this.preview
|
|
321
|
+
attachmentFile.url = this.directupload
|
|
322
|
+
attachmentFile.accepts = this.accepts
|
|
323
|
+
attachmentFile.max = this.max
|
|
324
|
+
attachmentFile.file = file
|
|
325
|
+
this._files.push(attachmentFile)
|
|
326
|
+
})
|
|
327
|
+
this.files = this._files
|
|
328
|
+
// Manually trigger DOM update since Stencil's state change may not re-render
|
|
329
|
+
// when called from external JavaScript
|
|
330
|
+
requestAnimationFrame(() => this.componentDidRender())
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Validations
|
|
334
|
+
|
|
335
|
+
checkValidity() {
|
|
336
|
+
if (this.required && this.files.length === 0) {
|
|
337
|
+
return false
|
|
338
|
+
}
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setCustomValidity(msg: string) {
|
|
343
|
+
this.internals.setValidity(msg ? { customError: true } : {}, msg, this.fileInput)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
reportValidity() {
|
|
347
|
+
return this.internals.reportValidity()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
get validationMessage() {
|
|
351
|
+
return this.internals.validationMessage
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# input-attachment
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
<!-- Auto Generated Below -->
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Properties
|
|
9
|
+
|
|
10
|
+
| Property | Attribute | Description | Type | Default |
|
|
11
|
+
| -------------- | -------------- | ----------- | --------- | ----------- |
|
|
12
|
+
| `accepts` | `accepts` | | `string` | `undefined` |
|
|
13
|
+
| `directupload` | `directupload` | | `string` | `undefined` |
|
|
14
|
+
| `disabled` | `disabled` | | `boolean` | `false` |
|
|
15
|
+
| `max` | `max` | | `number` | `undefined` |
|
|
16
|
+
| `multiple` | `multiple` | | `boolean` | `false` |
|
|
17
|
+
| `name` | `name` | | `string` | `undefined` |
|
|
18
|
+
| `preview` | `preview` | | `boolean` | `true` |
|
|
19
|
+
| `required` | `required` | | `boolean` | `false` |
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Shadow Parts
|
|
23
|
+
|
|
24
|
+
| Part | Description |
|
|
25
|
+
| --------- | ----------- |
|
|
26
|
+
| `"title"` | |
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Dependencies
|
|
30
|
+
|
|
31
|
+
### Depends on
|
|
32
|
+
|
|
33
|
+
- [attachment-file](../attachment-file)
|
|
34
|
+
|
|
35
|
+
### Graph
|
|
36
|
+
```mermaid
|
|
37
|
+
graph TD;
|
|
38
|
+
input-attachment --> attachment-file
|
|
39
|
+
attachment-file --> attachment-preview
|
|
40
|
+
style input-attachment fill:#f9f,stroke:#333,stroke-width:4px
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
----------------------------------------------
|
|
44
|
+
|
|
45
|
+
*Built with [StencilJS](https://stenciljs.com/)*
|