From fea5f8f3d418ad516ab1fb8c5abf76f227438f02 Mon Sep 17 00:00:00 2001
From: advplyr <advplyr@protonmail.com>
Date: Mon, 2 Sep 2024 16:50:22 -0500
Subject: [PATCH] Update:Batch edit page show confirmation before navigating
 away with unsaved changes #3369

---
 client/components/widgets/BookDetailsEdit.vue | 39 ++++++----
 .../components/widgets/PodcastDetailsEdit.vue | 30 +++++---
 client/pages/batch/index.vue                  | 74 ++++++++++---------
 3 files changed, 80 insertions(+), 63 deletions(-)

diff --git a/client/components/widgets/BookDetailsEdit.vue b/client/components/widgets/BookDetailsEdit.vue
index b42082b5..5fbcaa20 100644
--- a/client/components/widgets/BookDetailsEdit.vue
+++ b/client/components/widgets/BookDetailsEdit.vue
@@ -3,67 +3,67 @@
     <form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
       <div class="flex flex-wrap -mx-1">
         <div class="w-full md:w-1/2 px-1">
-          <ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" />
+          <ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" @input="handleInputChange" />
         </div>
         <div class="flex-grow px-1 mt-2 md:mt-0">
-          <ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" />
+          <ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" @input="handleInputChange" />
         </div>
       </div>
 
       <div class="flex flex-wrap mt-2 -mx-1">
         <div class="w-full md:w-3/4 px-1">
           <!-- Authors filter only contains authors in this library, uses filter data -->
-          <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" filter-key="authors" />
+          <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" filter-key="authors" @input="handleInputChange" />
         </div>
         <div class="flex-grow px-1 mt-2 md:mt-0">
-          <ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" :label="$strings.LabelPublishYear" />
+          <ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" :label="$strings.LabelPublishYear" @input="handleInputChange" />
         </div>
       </div>
 
       <div class="flex mt-2 -mx-1">
         <div class="flex-grow px-1">
-          <widgets-series-input-widget v-model="details.series" />
+          <widgets-series-input-widget v-model="details.series" @input="handleInputChange" />
         </div>
       </div>
 
-      <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" />
+      <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
 
       <div class="flex flex-wrap mt-2 -mx-1">
         <div class="w-full md:w-1/2 px-1">
-          <ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" />
+          <ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" @input="handleInputChange" />
         </div>
         <div class="flex-grow px-1 mt-2 md:mt-0">
-          <ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" />
+          <ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" @input="handleInputChange" />
         </div>
       </div>
 
       <div class="flex flex-wrap mt-2 -mx-1">
         <div class="w-full md:w-1/2 px-1">
-          <ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" />
+          <ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" @input="handleInputChange" />
         </div>
         <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
-          <ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
+          <ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" @input="handleInputChange" />
         </div>
         <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
-          <ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
+          <ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" @input="handleInputChange" />
         </div>
       </div>
 
       <div class="flex flex-wrap mt-2 -mx-1">
         <div class="w-full md:w-1/4 px-1">
-          <ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
+          <ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" @input="handleInputChange" />
         </div>
         <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
-          <ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" />
+          <ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" @input="handleInputChange" />
         </div>
         <div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
           <div class="flex justify-center">
-            <ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
+            <ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
           </div>
         </div>
         <div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
           <div class="flex justify-center">
-            <ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
+            <ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
           </div>
         </div>
       </div>
@@ -132,6 +132,12 @@ export default {
     }
   },
   methods: {
+    handleInputChange() {
+      this.$emit('change', {
+        libraryItemId: this.libraryItem.id,
+        hasChanges: this.checkForChanges().hasChanges
+      })
+    },
     getDetails() {
       this.forceBlur()
       return this.checkForChanges()
@@ -172,6 +178,7 @@ export default {
           }
         }
       }
+      this.handleInputChange()
     },
     forceBlur() {
       if (this.$refs.titleInput) this.$refs.titleInput.blur()
@@ -286,4 +293,4 @@ export default {
   },
   mounted() {}
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/client/components/widgets/PodcastDetailsEdit.vue b/client/components/widgets/PodcastDetailsEdit.vue
index 4c2fd739..20513ba5 100644
--- a/client/components/widgets/PodcastDetailsEdit.vue
+++ b/client/components/widgets/PodcastDetailsEdit.vue
@@ -3,45 +3,45 @@
     <form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
       <div class="flex -mx-1">
         <div class="w-1/2 px-1">
-          <ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" />
+          <ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" @input="handleInputChange" />
         </div>
         <div class="flex-grow px-1">
-          <ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" />
+          <ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" @input="handleInputChange" />
         </div>
       </div>
 
-      <ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" />
+      <ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" @input="handleInputChange" />
 
-      <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" />
+      <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
 
       <div class="flex mt-2 -mx-1">
         <div class="w-1/2 px-1">
-          <ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" />
+          <ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" @input="handleInputChange" />
         </div>
         <div class="flex-grow px-1">
-          <ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" />
+          <ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" @input="handleInputChange" />
         </div>
       </div>
 
       <div class="flex mt-2 -mx-1">
         <div class="w-1/4 px-1">
-          <ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" />
+          <ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" @input="handleInputChange" />
         </div>
         <div class="w-1/4 px-1">
-          <ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
+          <ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" @input="handleInputChange" />
         </div>
         <div class="w-1/4 px-1">
-          <ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" />
+          <ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" @input="handleInputChange" />
         </div>
         <div class="flex-grow px-1 pt-6">
           <div class="flex justify-center">
-            <ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
+            <ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
           </div>
         </div>
       </div>
       <div class="flex mt-2 -mx-1">
         <div class="w-1/4 px-1">
-          <ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" />
+          <ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" @input="handleInputChange" />
         </div>
       </div>
     </form>
@@ -105,6 +105,12 @@ export default {
     }
   },
   methods: {
+    handleInputChange() {
+      this.$emit('change', {
+        libraryItemId: this.libraryItem.id,
+        hasChanges: this.checkForChanges().hasChanges
+      })
+    },
     getDetails() {
       this.forceBlur()
       return this.checkForChanges()
@@ -136,6 +142,8 @@ export default {
           }
         }
       }
+
+      this.handleInputChange()
     },
     forceBlur() {
       if (this.$refs.titleInput) this.$refs.titleInput.blur()
diff --git a/client/pages/batch/index.vue b/client/pages/batch/index.vue
index 1f119387..5cc83176 100644
--- a/client/pages/batch/index.vue
+++ b/client/pages/batch/index.vue
@@ -97,8 +97,8 @@
     <div class="flex justify-center flex-wrap">
       <template v-for="libraryItem in libraryItemCopies">
         <div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px">
-          <widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
-          <widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
+          <widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
+          <widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
         </div>
       </template>
     </div>
@@ -108,7 +108,7 @@
 
     <div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamLibraryItem ? '165px' : '0px' }">
       <div class="flex-grow" />
-      <ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
+      <ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" :disabled="!hasChanges" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
     </div>
   </div>
 </template>
@@ -170,7 +170,8 @@ export default {
         abridged: false
       },
       appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
-      openMapOptions: false
+      openMapOptions: false,
+      itemsWithChanges: []
     }
   },
   computed: {
@@ -221,9 +222,19 @@ export default {
     },
     hasSelectedBatchUsage() {
       return Object.values(this.selectedBatchUsage).some((b) => !!b)
+    },
+    hasChanges() {
+      return this.itemsWithChanges.length > 0
     }
   },
   methods: {
+    handleItemChange(itemChange) {
+      if (!itemChange.hasChanges) {
+        this.itemsWithChanges = this.itemsWithChanges.filter((id) => id !== itemChange.libraryItemId)
+      } else if (!this.itemsWithChanges.includes(itemChange.libraryItemId)) {
+        this.itemsWithChanges.push(itemChange.libraryItemId)
+      }
+    },
     blurBatchForm() {
       if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) {
         this.$refs.seriesSelect.forceBlur()
@@ -283,38 +294,10 @@ export default {
     removedSeriesItem(item) {},
     newNarratorItem(item) {},
     removedNarratorItem(item) {},
-    newTagItem(item) {
-      // if (item && !this.newTagItems.includes(item)) {
-      //   this.newTagItems.push(item)
-      // }
-    },
-    removedTagItem(item) {
-      // If newly added, remove if not used on any other items
-      // if (item && this.newTagItems.includes(item)) {
-      //   var usedByOtherAb = this.libraryItemCopies.find((ab) => {
-      //     return ab.tags && ab.tags.includes(item)
-      //   })
-      //   if (!usedByOtherAb) {
-      //     this.newTagItems = this.newTagItems.filter((t) => t !== item)
-      //   }
-      // }
-    },
-    newGenreItem(item) {
-      // if (item && !this.newGenreItems.includes(item)) {
-      //   this.newGenreItems.push(item)
-      // }
-    },
-    removedGenreItem(item) {
-      // If newly added, remove if not used on any other items
-      // if (item && this.newGenreItems.includes(item)) {
-      //   var usedByOtherAb = this.libraryItemCopies.find((ab) => {
-      //     return ab.book.genres && ab.book.genres.includes(item)
-      //   })
-      //   if (!usedByOtherAb) {
-      //     this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
-      //   }
-      // }
-    },
+    newTagItem(item) {},
+    removedTagItem(item) {},
+    newGenreItem(item) {},
+    removedGenreItem(item) {},
     init() {
       // TODO: Better deep cloning of library items
       this.libraryItemCopies = this.libraryItems.map((li) => {
@@ -376,6 +359,7 @@ export default {
         .then((data) => {
           this.isProcessing = false
           if (data.updates) {
+            this.itemsWithChanges = []
             this.$toast.success(`Successfully updated ${data.updates} items`)
             this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
           } else {
@@ -387,10 +371,28 @@ export default {
           this.$toast.error('Failed to batch update')
           this.isProcessing = false
         })
+    },
+    beforeUnload(e) {
+      if (!e || !this.hasChanges) return
+      e.preventDefault()
+      e.returnValue = ''
+    }
+  },
+  beforeRouteLeave(to, from, next) {
+    if (this.hasChanges) {
+      next(false)
+      window.location = to.path
+    } else {
+      next()
     }
   },
   mounted() {
     this.init()
+
+    window.addEventListener('beforeunload', this.beforeUnload)
+  },
+  beforeDestroy() {
+    window.removeEventListener('beforeunload', this.beforeUnload)
   }
 }
 </script>