Browse Source

feat: Added bulk actions modal for admin/songs

Owen Diffey 3 years ago
parent
commit
b34734d2e4

+ 198 - 3
backend/logic/actions/songs.js

@@ -1744,7 +1744,7 @@ export default {
 	 * @param session
 	 * @param cb
 	 */
-	getGenres: isAdminRequired(function getModule(session, cb) {
+	getGenres: isAdminRequired(function getGenres(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1774,13 +1774,78 @@ export default {
 		);
 	}),
 
+	/**
+	 * Bulk update genres for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace genres
+	 * @param genres Array of genres to apply
+	 * @param songIds Array of songIds to apply genres to
+	 * @param cb
+	 */
+	editGenres: isAdminRequired(async function editGenres(session, method, genres, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { genres: { $each: genres } };
+					} else if (method === "remove") {
+						query.$pullAll = { genres };
+					} else if (method === "replace") {
+						query.$set = { genres };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						async.eachLimit(
+							songsFound,
+							1,
+							(songId, next) => {
+								SongsModule.runJob("UPDATE_SONG", { songId });
+								next();
+							},
+							next
+						);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_GENRES", `User ${session.userId} failed to edit genres. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_GENRES", `User ${session.userId} has successfully edited genres.`);
+					cb({
+						status: "success",
+						message: "Successfully edited genres."
+					});
+				}
+			}
+		);
+	}),
+
 	/**
 	 * Gets a list of all artists
 	 *
 	 * @param session
 	 * @param cb
 	 */
-	getArtists: isAdminRequired(function getModule(session, cb) {
+	getArtists: isAdminRequired(function getArtists(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1810,13 +1875,78 @@ export default {
 		);
 	}),
 
+	/**
+	 * Bulk update artists for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace artists
+	 * @param artists Array of artists to apply
+	 * @param songIds Array of songIds to apply artists to
+	 * @param cb
+	 */
+	editArtists: isAdminRequired(async function editArtists(session, method, artists, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { artists: { $each: artists } };
+					} else if (method === "remove") {
+						query.$pullAll = { artists };
+					} else if (method === "replace") {
+						query.$set = { artists };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						async.eachLimit(
+							songsFound,
+							1,
+							(songId, next) => {
+								SongsModule.runJob("UPDATE_SONG", { songId });
+								next();
+							},
+							next
+						);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_ARTISTS", `User ${session.userId} failed to edit artists. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_ARTISTS", `User ${session.userId} has successfully edited artists.`);
+					cb({
+						status: "success",
+						message: "Successfully edited artists."
+					});
+				}
+			}
+		);
+	}),
+
 	/**
 	 * Gets a list of all tags
 	 *
 	 * @param session
 	 * @param cb
 	 */
-	getTags: isAdminRequired(function getModule(session, cb) {
+	getTags: isAdminRequired(function getTags(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1844,5 +1974,70 @@ export default {
 				}
 			}
 		);
+	}),
+
+	/**
+	 * Bulk update tags for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace tags
+	 * @param tags Array of tags to apply
+	 * @param songIds Array of songIds to apply tags to
+	 * @param cb
+	 */
+	editTags: isAdminRequired(async function editTags(session, method, tags, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { tags: { $each: tags } };
+					} else if (method === "remove") {
+						query.$pullAll = { tags };
+					} else if (method === "replace") {
+						query.$set = { tags };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						async.eachLimit(
+							songsFound,
+							1,
+							(songId, next) => {
+								SongsModule.runJob("UPDATE_SONG", { songId });
+								next();
+							},
+							next
+						);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_TAGS", `User ${session.userId} failed to edit tags. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_TAGS", `User ${session.userId} has successfully edited tags.`);
+					cb({
+						status: "success",
+						message: "Successfully edited tags."
+					});
+				}
+			}
+		);
 	})
 };

+ 159 - 0
frontend/src/components/modals/BulkActions.vue

@@ -0,0 +1,159 @@
+<template>
+	<div>
+		<modal title="Bulk Actions" class="bulk-actions-modal">
+			<template #body>
+				<label class="label">Method</label>
+				<div class="control is-expanded select">
+					<select v-model="method">
+						<option value="add">Add</option>
+						<option value="remove">Remove</option>
+						<option value="replace">Replace</option>
+					</select>
+				</div>
+
+				<label class="label">{{ type.name.slice(0, -1) }}</label>
+				<div class="control is-grouped input-with-button">
+					<input
+						v-model="itemInput"
+						class="input"
+						type="text"
+						:placeholder="`Enter ${type.name} to ${method}`"
+						autocomplete="off"
+						@keypress.enter="addItem()"
+					/>
+					<p class="control">
+						<button
+							class="button is-primary material-icons"
+							@click="addItem()"
+						>
+							add
+						</button>
+					</p>
+				</div>
+
+				<label class="label"
+					>{{ type.name }} to be
+					{{ method === "add" ? `added` : `${method}d` }}</label
+				>
+				<div v-if="items.length > 0">
+					<div
+						v-for="(item, index) in items"
+						:key="`item-${item}`"
+						class="tag"
+					>
+						{{ item }}
+						<span
+							class="material-icons remove-item"
+							@click="removeItem(index)"
+							content="Remove item"
+							v-tippy
+							>highlight_off</span
+						>
+					</div>
+				</div>
+				<p v-else>No {{ type.name }} specified</p>
+			</template>
+			<template #footer>
+				<button
+					class="button is-primary"
+					:disabled="items.length === 0"
+					@click="applyChanges()"
+				>
+					Apply Changes
+				</button>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import Modal from "../Modal.vue";
+
+export default {
+	components: { Modal },
+	props: {
+		type: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	data() {
+		return {
+			method: "add",
+			items: [],
+			itemInput: null
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	beforeUnmount() {
+		this.itemInput = null;
+		this.items = [];
+	},
+	methods: {
+		addItem() {
+			if (!this.itemInput) return;
+			if (!this.items.includes(this.itemInput))
+				this.items.push(this.itemInput);
+			this.itemInput = null;
+		},
+		removeItem(index) {
+			this.items.splice(index, 1);
+		},
+		applyChanges() {
+			this.socket.dispatch(
+				this.type.action,
+				this.method,
+				this.items,
+				this.type.items,
+				res => {
+					new Toast(res.message);
+					this.closeModal("bulkActions");
+				}
+			);
+		},
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.label {
+	text-transform: capitalize;
+}
+
+.select.is-expanded select {
+	width: 100%;
+}
+
+.tag {
+	display: inline-flex;
+	margin: 5px;
+	padding: 5px 10px;
+	font-size: 14px;
+	font-weight: 600;
+	border-radius: 5px;
+	background-color: var(--primary-color);
+	color: var(--white);
+	transition: all 0.2s ease-in-out;
+
+	&:hover,
+	&:focus {
+		filter: brightness(90%);
+		transition: all 0.2s ease-in-out;
+	}
+
+	.remove-item {
+		font-size: 16px;
+		margin: auto 2px auto 5px;
+		cursor: pointer;
+	}
+}
+</style>

+ 29 - 9
frontend/src/pages/Admin/tabs/Songs.vue

@@ -228,8 +228,8 @@
 						</quick-confirm>
 						<i
 							class="material-icons tag-songs-icon"
-							@click.prevent="tagMany(slotProps.item)"
-							content="Tag Songs"
+							@click.prevent="setTags(slotProps.item)"
+							content="Set Tags"
 							v-tippy
 							tabindex="0"
 						>
@@ -277,6 +277,7 @@
 		<edit-song v-if="modals.editSong" song-type="songs" :key="song._id" />
 		<report v-if="modals.report" />
 		<request-song v-if="modals.requestSong" />
+		<bulk-actions v-if="modals.bulkActions" :type="bulkActionsType" />
 		<confirm v-if="modals.confirm" @confirmed="handleConfirmed()" />
 	</div>
 </template>
@@ -306,6 +307,9 @@ export default {
 		RequestSong: defineAsyncComponent(() =>
 			import("@/components/modals/RequestSong.vue")
 		),
+		BulkActions: defineAsyncComponent(() =>
+			import("@/components/modals/BulkActions.vue")
+		),
 		Confirm: defineAsyncComponent(() =>
 			import("@/components/modals/Confirm.vue")
 		),
@@ -638,7 +642,8 @@ export default {
 				message: "",
 				action: "",
 				params: null
-			}
+			},
+			bulkActionsType: null
 		};
 	},
 	computed: {
@@ -706,14 +711,29 @@ export default {
 				}
 			);
 		},
-		tagMany() {
-			new Toast("Bulk tagging not yet implemented.");
+		setTags(selectedRows) {
+			this.bulkActionsType = {
+				name: "tags",
+				action: "songs.editTags",
+				items: selectedRows.map(row => row._id)
+			};
+			this.openModal("bulkActions");
 		},
-		setArtists() {
-			new Toast("Bulk setting artists not yet implemented.");
+		setArtists(selectedRows) {
+			this.bulkActionsType = {
+				name: "artists",
+				action: "songs.editArtists",
+				items: selectedRows.map(row => row._id)
+			};
+			this.openModal("bulkActions");
 		},
-		setGenres() {
-			new Toast("Bulk setting genres not yet implemented.");
+		setGenres(selectedRows) {
+			this.bulkActionsType = {
+				name: "genres",
+				action: "songs.editGenres",
+				items: selectedRows.map(row => row._id)
+			};
+			this.openModal("bulkActions");
 		},
 		deleteOne(songId) {
 			this.socket.dispatch("songs.remove", songId, res => {

+ 2 - 1
frontend/src/store/modules/modalVisibility.js

@@ -20,7 +20,8 @@ const state = {
 		viewReport: false,
 		viewPunishment: false,
 		confirm: false,
-		editSongConfirm: false
+		editSongConfirm: false,
+		bulkActions: false
 	},
 	currentlyActive: []
 };