diff --git a/js/app.js b/js/app.js index 4ed69712..adf62dd1 100644 --- a/js/app.js +++ b/js/app.js @@ -5343,6 +5343,9 @@ class Application { if (!this.pubkey) { this.showError("Please login to edit videos."); + if (this.editModal?.setSubmitState) { + this.editModal.setSubmitState({ pending: false }); + } return; } @@ -5355,11 +5358,17 @@ class Application { await this.loadVideos(); this.videosMap.clear(); this.showSuccess("Video updated successfully!"); + if (this.editModal?.setSubmitState) { + this.editModal.setSubmitState({ pending: false }); + } this.editModal.close(); this.forceRefreshAllProfiles(); } catch (error) { console.error("Failed to edit video:", error); this.showError("Failed to edit video. Please try again."); + if (this.editModal?.setSubmitState) { + this.editModal.setSubmitState({ pending: false }); + } } } diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index c40274f8..ff410f1a 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -59,6 +59,9 @@ export class EditModal { this.eventsBound = false; this.loadPromise = null; + this.pendingSubmit = false; + this.pendingSubmitVideo = null; + this.nip71FormManager = new Nip71FormManager(); this.nip71SectionKey = "edit"; this.originalNip71Metadata = null; @@ -143,6 +146,8 @@ export class EditModal { this.cancelButton = context.querySelector("#cancelEditVideo") || null; this.submitButton = context.querySelector("#submitEditVideo") || null; + this.updateSubmitButtonState(); + this.fieldButtons = Array.from(context.querySelectorAll("[data-edit-target]")); this.fields = { @@ -251,6 +256,7 @@ export class EditModal { this.nip71FormManager.resetSection(this.nip71SectionKey); this.originalNip71Metadata = null; this.originalNip71MetadataJson = null; + this.setSubmitState({ pending: false }); return; } @@ -295,6 +301,7 @@ export class EditModal { this.originalNip71Metadata = null; this.originalNip71MetadataJson = null; this.activeVideo = null; + this.setSubmitState({ pending: false }); } async open(video) { @@ -677,11 +684,17 @@ export class EditModal { } submit() { + if (this.pendingSubmit) { + return; + } + if (!this.activeVideo || !this.root) { this.showError("No video selected for editing."); return; } + this.setSubmitState({ pending: true, video: this.activeVideo }); + const fieldValue = (key) => { const input = this.fields[key]; if (!input || typeof input.value !== "string") { @@ -696,7 +709,7 @@ export class EditModal { return this.sanitizers.text(input.value); }; - const original = this.activeVideo; + const original = this.pendingSubmitVideo || this.activeVideo; const titleInput = this.fields.title; const urlInput = this.fields.url; @@ -843,4 +856,30 @@ export class EditModal { video: { ...original }, }); } + + setSubmitState({ pending = false, video } = {}) { + const nextPending = Boolean(pending); + if (nextPending) { + this.pendingSubmitVideo = video || this.activeVideo || this.pendingSubmitVideo; + } else { + this.pendingSubmitVideo = null; + } + + this.pendingSubmit = nextPending; + this.updateSubmitButtonState(); + } + + updateSubmitButtonState() { + if (!this.submitButton) { + return; + } + + if (this.pendingSubmit) { + this.submitButton.disabled = true; + this.submitButton.setAttribute("disabled", "disabled"); + } else { + this.submitButton.disabled = false; + this.submitButton.removeAttribute("disabled"); + } + } } diff --git a/tests/edit-modal-submit-state.test.mjs b/tests/edit-modal-submit-state.test.mjs new file mode 100644 index 00000000..4e1a2f80 --- /dev/null +++ b/tests/edit-modal-submit-state.test.mjs @@ -0,0 +1,143 @@ +import test, { beforeEach, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { JSDOM } from 'jsdom'; + +import { EditModal } from '../js/ui/components/EditModal.js'; + +const editModalHtml = await readFile( + new URL('../components/edit-video-modal.html', import.meta.url), + 'utf8', +); + +let dom; +let container; + +beforeEach(() => { + dom = new JSDOM( + '
', + { + url: 'https://example.com', + pretendToBeVisual: true, + }, + ); + + global.window = dom.window; + global.document = dom.window.document; + global.HTMLElement = dom.window.HTMLElement; + global.HTMLInputElement = dom.window.HTMLInputElement; + global.HTMLButtonElement = dom.window.HTMLButtonElement; + global.CustomEvent = dom.window.CustomEvent; + global.Event = dom.window.Event; + global.EventTarget = dom.window.EventTarget; + + container = document.getElementById('modalContainer'); + + global.fetch = async (resource) => { + if ( + typeof resource === 'string' && + resource.includes('components/edit-video-modal.html') + ) { + return { + ok: true, + status: 200, + text: async () => editModalHtml, + }; + } + + throw new Error(`Unexpected fetch: ${resource}`); + }; +}); + +afterEach(() => { + delete global.fetch; + delete global.EventTarget; + delete global.Event; + delete global.CustomEvent; + delete global.HTMLButtonElement; + delete global.HTMLInputElement; + delete global.HTMLElement; + delete global.document; + delete global.window; + + if (dom) { + dom.window.close(); + dom = null; + } + container = null; +}); + +function createModal({ + showError, + eventTarget = new EventTarget(), +} = {}) { + return new EditModal({ + removeTrackingScripts: () => {}, + setGlobalModalState: () => {}, + showError: showError || (() => {}), + getMode: () => 'live', + eventTarget, + container, + }); +} + +test('ignores additional submissions while pending without spurious errors', async () => { + const errorMessages = []; + const modalEvents = new EventTarget(); + const modal = createModal({ + showError: (message) => { + if (message) { + errorMessages.push(message); + } + }, + eventTarget: modalEvents, + }); + + await modal.load({ container }); + + const video = { + id: 'video1', + pubkey: 'pubkey1', + title: 'Sample video', + url: 'https://example.com/video.mp4', + thumbnail: '', + description: '', + enableComments: true, + isPrivate: false, + }; + + await modal.open(video); + + assert.ok(modal.submitButton, 'expected submit button to be cached'); + assert.equal(modal.submitButton.disabled, false); + + const submissions = []; + modal.addEventListener('video:edit-submit', (event) => { + submissions.push(event.detail); + }); + + modal.submit(); + + assert.equal(submissions.length, 1, 'first submit should emit once'); + assert.equal(modal.pendingSubmit, true, 'pending flag should be set after submit'); + assert.equal( + modal.pendingSubmitVideo?.id, + video.id, + 'pending video context should match the edited video', + ); + assert.equal(modal.submitButton.disabled, true, 'button should disable while pending'); + + modal.submit(); + + assert.equal(submissions.length, 1, 'duplicate submit should be ignored'); + assert.equal( + errorMessages.includes('No video selected for editing.'), + false, + 'should not surface missing video error during pending submit', + ); + + modal.setSubmitState({ pending: false }); + + assert.equal(modal.pendingSubmit, false, 'pending flag should reset after release'); + assert.equal(modal.submitButton.disabled, false, 'button should re-enable after release'); +});