| | @@ -41,24 +41,26 @@ |
| 41 | 41 | |
| 42 | 42 | opt.controls = [array of DOM elements]. Optional DOM elements |
| 43 | 43 | to inject into the UI element which wraps the "Add" button. |
| 44 | 44 | See this.controlsElement. |
| 45 | 45 | |
| 46 | | - opt.listener = {add: func, remove: func, populate: func}: if |
| 47 | | - these are functions they are registered as listeners for |
| 48 | | - 'entry-added', 'entry-removed', and/or 'entry-populated' |
| 49 | | - events, described below. opt.listener.all, if set, is used |
| 50 | | - as a fallback for any of 'add', 'remove', or 'populate' |
| 51 | | - which are not set. |
| 46 | + opt.listener = function or object: {add: func, remove: func, |
| 47 | + populate: func}: if these are functions they are registered as |
| 48 | + listeners for 'entry-added', 'entry-removed', and/or |
| 49 | + 'entry-populated' events, described below. opt.listener.all, if |
| 50 | + set, is used as a fallback for any of 'add', 'remove', or |
| 51 | + 'populate' which are not set. If opt.listener is a function |
| 52 | + then it behaves as if listener={all: thatFunction}. |
| 52 | 53 | |
| 53 | 54 | Events: |
| 54 | 55 | |
| 55 | 56 | This class fires CustomEvents for certain changes: |
| 56 | 57 | |
| 57 | 58 | 'entry-added' and 'entry-removed' trigger when an attachment |
| 58 | | - entry row is added/removed. Its event.detail is {attacher: |
| 59 | | - this, row: object, type: 'same as event type'}. |
| 59 | + entry row is added/removed. Its event.detail is: |
| 60 | + |
| 61 | + {attacher: this, row: object, type: 'same as event type'}. |
| 60 | 62 | |
| 61 | 63 | 'entry-populated' is triggered when a visible entry gets |
| 62 | 64 | content attached to it, with the same detail structure as |
| 63 | 65 | described above. |
| 64 | 66 | |
| | @@ -67,34 +69,49 @@ |
| 67 | 69 | */ |
| 68 | 70 | constructor(opt){ |
| 69 | 71 | this.#opt = opt = F.nu({ |
| 70 | 72 | addButtonLabel: false, |
| 71 | 73 | startWith: 0, |
| 72 | | - limit: 0 |
| 74 | + limit: 0, |
| 75 | + dryRun: false |
| 73 | 76 | }, opt); |
| 77 | + this.#e.body = D.addClass(D.div(), 'attach-widget'); |
| 74 | 78 | const eBtnAdd = this.#e.btnAdd = D.addClass( |
| 75 | 79 | D.button(this.#opt.addButtonLabel || 'Add attachment', |
| 76 | 80 | ()=>this.#addRow()), |
| 77 | 81 | 'attach-add-button' |
| 78 | 82 | ); |
| 79 | 83 | eBtnAdd.type = 'button'; |
| 84 | + this.#e.err = D.addClass(D.div(), 'error', 'hidden'); |
| 85 | + this.#e.body.append(this.#e.err); |
| 86 | + |
| 80 | 87 | const eControls = this.#e.controls = |
| 81 | 88 | D.addClass(D.div(), 'attach-controls'); |
| 82 | 89 | eControls.append(eBtnAdd); |
| 83 | | - this.#e.list = D.addClass(D.div(), 'attach-widget'); |
| 84 | | - opt.container.appendChild(this.#e.list); |
| 85 | | - this.#e.list.appendChild(eControls); |
| 90 | + opt.container.appendChild(this.#e.body); |
| 91 | + this.#e.body.appendChild(eControls); |
| 86 | 92 | if( opt.listener ){ |
| 87 | | - const doCb = (eventType, cb)=>{ |
| 88 | | - const f = cb || opt.listener.all; |
| 93 | + const doCb = (eventType, key)=>{ |
| 94 | + const f = (opt.listener instanceof Function) |
| 95 | + ? opt.listener |
| 96 | + : (opt.listener[key] || opt.listener.all); |
| 89 | 97 | if( f instanceof Function ){ |
| 90 | 98 | this.addEventListener(eventType, f); |
| 91 | 99 | } |
| 92 | 100 | }; |
| 93 | | - doCb('entry-added', opt.listener.add); |
| 94 | | - doCb('entry-removed', opt.listener.remove); |
| 95 | | - doCb('entry-populated', opt.listener.populate); |
| 101 | + doCb('entry-added', 'add'); |
| 102 | + doCb('entry-removed', 'remove'); |
| 103 | + doCb('entry-populated', 'populate'); |
| 104 | + } |
| 105 | + if( 0 ){ |
| 106 | + /* Add dry-run toggle for testing. */ |
| 107 | + const eLbl = D.label(false, "Dry-run?"); |
| 108 | + const eCb = D.checkbox(true); |
| 109 | + eLbl.append(eCb); |
| 110 | + eControls.append(eLbl); |
| 111 | + eCb.checked = opt.dryRun = true; |
| 112 | + eCb.addEventListener('change',()=>opt.dryRun=eCb.checked); |
| 96 | 113 | } |
| 97 | 114 | if( Array.isArray(opt.controls) ){ |
| 98 | 115 | eControls.append(...opt.controls); |
| 99 | 116 | } |
| 100 | 117 | if( opt.startWith > 0 ){ |
| | @@ -121,17 +138,36 @@ |
| 121 | 138 | if( r.file ) return true; |
| 122 | 139 | } |
| 123 | 140 | return false; |
| 124 | 141 | } |
| 125 | 142 | |
| 143 | + get isDryRun(){ |
| 144 | + return !!this.#opt.dryRun; |
| 145 | + } |
| 126 | 146 | /** |
| 127 | 147 | Returns the DOM element (div.attach-controls) which wraps the |
| 128 | 148 | "Add" button. Clients may add buttons to it. |
| 129 | 149 | */ |
| 130 | 150 | get controlsElement(){ |
| 131 | 151 | return this.#e.controls; |
| 132 | 152 | } |
| 153 | + |
| 154 | + /** |
| 155 | + Reports an error by appending each argument to the error widget |
| 156 | + and unhiding it. If passed no arugments, it clears and hides |
| 157 | + the error widget. |
| 158 | + */ |
| 159 | + reportError(...msg){ |
| 160 | + const e = this.#e.err; |
| 161 | + D.clearElement(e); |
| 162 | + if( msg.length ){ |
| 163 | + e.classList.remove('hidden'); |
| 164 | + e.append(...msg); |
| 165 | + }else{ |
| 166 | + e.classList.add('hidden'); |
| 167 | + } |
| 168 | + } |
| 133 | 169 | |
| 134 | 170 | #removeRow(rowObj){ |
| 135 | 171 | rowObj.e.row.remove(); |
| 136 | 172 | this.#rows = this.#rows.filter(v=>v!==rowObj); |
| 137 | 173 | this.#updateControls(); |
| | @@ -148,28 +184,39 @@ |
| 148 | 184 | && this.#opt.startWith>0 ){ |
| 149 | 185 | /* Intended primarily for /addattach. */ |
| 150 | 186 | this.#addRow(); |
| 151 | 187 | } |
| 152 | 188 | } |
| 189 | + |
| 190 | + clear(){ |
| 191 | + for(const r of [...this.#rows/*clone because #rows may change*/]){ |
| 192 | + this.#removeRow(r); |
| 193 | + } |
| 194 | + this.reportError(); |
| 195 | + } |
| 153 | 196 | |
| 154 | 197 | /** |
| 155 | | - Hide or show the Add button, as appropriate. |
| 198 | + Hides or shows the Add button, as appropriate. |
| 156 | 199 | */ |
| 157 | 200 | #updateControls(){ |
| 158 | 201 | const b = this.#e.btnAdd; |
| 159 | 202 | if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){ |
| 160 | 203 | b.classList.add('hidden'); |
| 161 | | - //b.setAttribute('disabled',''); |
| 204 | + D.disable(b); |
| 162 | 205 | //F.toast.warning("Attachment form limit reached."); |
| 163 | 206 | }else{ |
| 164 | 207 | b.classList.remove('hidden'); |
| 165 | | - //b.removeAttribute('disabled'); |
| 166 | | - this.#e.list.append(this.#e.controls/*move to the end*/); |
| 208 | + D.enable(b); |
| 209 | + this.#e.body.append(this.#e.controls/*move to the end*/); |
| 167 | 210 | } |
| 168 | 211 | } |
| 169 | 212 | |
| 170 | | - #rowError(rowObj, ...msg){ |
| 213 | + /** |
| 214 | + Sets rowObj.e.err up with an error message, or clears it if |
| 215 | + passed only 1 argument. |
| 216 | + */ |
| 217 | + #rowError(rowObj,...msg){ |
| 171 | 218 | let e = rowObj.e.err; |
| 172 | 219 | if( e ){ |
| 173 | 220 | D.clearElement(e); |
| 174 | 221 | }else{ |
| 175 | 222 | if( !msg.length ) return; |
| | @@ -274,11 +321,11 @@ |
| 274 | 321 | size: eSize, |
| 275 | 322 | desc: eDesc, |
| 276 | 323 | row: eRow, |
| 277 | 324 | remove: eRemove |
| 278 | 325 | }); |
| 279 | | - this.#e.list.append(eRow); |
| 326 | + this.#e.body.append(eRow); |
| 280 | 327 | this.#rows.push( rowObj ); |
| 281 | 328 | this.#updateControls(); |
| 282 | 329 | this.#events.dispatchEvent( |
| 283 | 330 | new CustomEvent('entry-added',{ |
| 284 | 331 | detail: F.nu({ |
| | @@ -322,12 +369,13 @@ |
| 322 | 369 | }else if( file.size < 1000000 ){ |
| 323 | 370 | szLbl = (file.size / 1024).toFixed(2)+' KB'; |
| 324 | 371 | }else{ |
| 325 | 372 | szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB'; |
| 326 | 373 | } |
| 374 | + this.#rowError(rowObj); |
| 327 | 375 | const old = this.#rowMatchingName(file.name); |
| 328 | | - if( old && rowObj !== old){ |
| 376 | + if( old && rowObj !== old ){ |
| 329 | 377 | /* |
| 330 | 378 | Fossil attachments treat the name as a unique-per-target |
| 331 | 379 | key, with the newest one being the primary. If a name is |
| 332 | 380 | given twice, remove the new entry and reuse the older |
| 333 | 381 | one. There are conceivable, but also unlikely, cases where |
| | @@ -335,10 +383,11 @@ |
| 335 | 383 | /foo/bar and /baz/bar, but that seems like a lesser evil |
| 336 | 384 | than attaching the same file N times, leading to N |
| 337 | 385 | attachment artifacts. |
| 338 | 386 | */ |
| 339 | 387 | /* recycle `old` instead to avoid UI flicker. */ |
| 388 | + this.#rowError(old); |
| 340 | 389 | this.#removeRow(rowObj); |
| 341 | 390 | rowObj.e = old.e; |
| 342 | 391 | } |
| 343 | 392 | rowObj.file = file; |
| 344 | 393 | rowObj.mimeType = file.type || 'application/octet-stream'; |
| | @@ -419,10 +468,12 @@ |
| 419 | 468 | } |
| 420 | 469 | }/*Attacher*/; |
| 421 | 470 | F.Attacher = Attacher; |
| 422 | 471 | |
| 423 | 472 | if( document.body.classList.contains('cpage-attachaddV2') ){ |
| 473 | + const urlArgs = new URLSearchParams(window.location.search); |
| 474 | + let zTarget = urlArgs.get('target'); |
| 424 | 475 | const eFormDiv = document.querySelector('#attachadd-form-wrapper'); |
| 425 | 476 | const eBtnSubmit = D.button("Submit"); |
| 426 | 477 | eBtnSubmit.type = 'button'; |
| 427 | 478 | const updateBtnSubmit = (attacher)=>{ |
| 428 | 479 | if( attacher.isPopulated ){ |
| | @@ -433,19 +484,67 @@ |
| 433 | 484 | }; |
| 434 | 485 | const cbAttacherChange = (ev)=>{ |
| 435 | 486 | const a = ev.detail.attacher; |
| 436 | 487 | updateBtnSubmit(a); |
| 437 | 488 | }; |
| 438 | | - const cbSubmit = (ev)=>{ |
| 439 | | - }; |
| 440 | | - eBtnSubmit.addEventListener('click', cbSubmit, false); |
| 441 | 489 | const att = new Attacher({ |
| 442 | 490 | container: eFormDiv, |
| 443 | 491 | startWith: 1, |
| 444 | | - listener: F.nu({all: cbAttacherChange}), |
| 492 | + listener: cbAttacherChange, |
| 445 | 493 | controls: [eBtnSubmit] |
| 446 | 494 | }); |
| 495 | + eBtnSubmit.addEventListener('click', async (ev)=>{ |
| 496 | + att.reportError(); |
| 497 | + const li = att.collectState(); |
| 498 | + if( !li.length ) return; |
| 499 | + if( eBtnSubmit.dataset.submitted ) return; |
| 500 | + eBtnSubmit.dataset.submitted = 1; |
| 501 | + D.disable(eBtnSubmit); |
| 502 | + const fd = new FormData(); |
| 503 | + let i = 0; |
| 504 | + for(const row of li){ |
| 505 | + ++i; |
| 506 | + fd.append(`file${i}`, row.content); |
| 507 | + if( row.description ) fd.append(`file${i}_desc`, row.description); |
| 508 | + } |
| 509 | + for( const eIn of eFormDiv.querySelectorAll(':scope > input[type="hidden"]') ){ |
| 510 | + if( eIn.name==='target' ){ |
| 511 | + zTarget = eIn.value; |
| 512 | + } |
| 513 | + fd.append(eIn.name, eIn.value) |
| 514 | + } |
| 515 | + if( att.isDryRun ){ |
| 516 | + fd.append('dryrun', '1'); |
| 517 | + } |
| 518 | + let err; |
| 519 | + const resp = await window.fetch(F.repoUrl('attachaddV2_ajax_post'), { |
| 520 | + method: 'POST', |
| 521 | + body: fd |
| 522 | + }).catch((e)=>{ |
| 523 | + err = e; |
| 524 | + }); |
| 525 | + D.enable(eBtnSubmit); |
| 526 | + delete eBtnSubmit.dataset.submitted; |
| 527 | + const jr = err ? undefined : await resp.json().catch(()=>{}); |
| 528 | + if( jr?.error || !resp.ok ){ |
| 529 | + const msg = err ? err.message : (jr?.error || resp.statusText); |
| 530 | + att.reportError("Attaching failed: ", msg); |
| 531 | + }else{ |
| 532 | + att.clear(); |
| 533 | + const to = urlArgs.get('to') || urlArgs.get('from') || jr?.redirect; |
| 534 | + if( to ){ |
| 535 | + if( '/'===to[0] ){ |
| 536 | + to = F.repoUrl(to.substr(1)); |
| 537 | + } |
| 538 | + window.location = to; |
| 539 | + }else{ |
| 540 | + const tgt = '?target='+zTarget+'&cacheBuster='+Date.now(); |
| 541 | + //console.error("FIXME: location=",tgt); |
| 542 | + window.location = tgt; |
| 543 | + } |
| 544 | + } |
| 545 | + })/*submit handler*/; |
| 447 | 546 | updateBtnSubmit(att); |
| 448 | 547 | F.page.attacher = att /* only for testing via dev console */; |
| 449 | 548 | }/* /attachaddV2 */ |
| 450 | 549 | |
| 451 | 550 | })(window.fossil); |
| 452 | 551 | |