Просмотр исходного кода

refactor: Started improving form data change and modal unsaved changes handling

Owen Diffey 2 лет назад
Родитель
Сommit
1432585446

+ 2 - 7
frontend/src/App.vue

@@ -51,7 +51,7 @@ const {
 	changeAnonymousSongRequests,
 	changeActivityWatch
 } = userPreferencesStore;
-const { modals, activeModals } = storeToRefs(modalsStore);
+const { activeModals } = storeToRefs(modalsStore);
 const { openModal, closeCurrentModal } = modalsStore;
 
 const aModalIsOpen = computed(() => Object.keys(activeModals.value).length > 0);
@@ -165,12 +165,7 @@ onMounted(async () => {
 		shift: false,
 		ctrl: false,
 		handler: () => {
-			if (
-				Object.keys(activeModals.value).length !== 0 &&
-				modals.value[
-					activeModals.value[activeModals.value.length - 1]
-				] !== "editSong"
-			)
+			if (Object.keys(activeModals.value).length !== 0)
 				closeCurrentModal();
 		}
 	});

+ 1 - 11
frontend/src/components/Modal.spec.ts

@@ -41,17 +41,7 @@ describe("Modal component", () => {
 		).toBeTruthy();
 	});
 
-	test("click to close modal emits if intercepted", async ({ wrapper }) => {
-		await wrapper.setProps({ interceptClose: true });
-		await wrapper.find(".modal-background").trigger("click");
-		await wrapper.find(".modal-card-head > .delete").trigger("click");
-		expect(wrapper.emitted()).toHaveProperty("close");
-		expect(wrapper.emitted().close).toHaveLength(2);
-	});
-
-	test("click to close modal calls store action if not intercepted", async ({
-		wrapper
-	}) => {
+	test("click to close modal calls store action", async ({ wrapper }) => {
 		const modalsStore = useModalsStore();
 		await wrapper.find(".modal-background").trigger("click");
 		await wrapper.find(".modal-card-head > .delete").trigger("click");

+ 4 - 14
frontend/src/components/Modal.vue

@@ -6,24 +6,16 @@ const ChristmasLights = defineAsyncComponent(
 	() => import("@/components/ChristmasLights.vue")
 );
 
-const props = defineProps({
+defineProps({
 	title: { type: String, default: "Modal" },
 	size: { type: String, default: null },
-	split: { type: Boolean, default: false },
-	interceptClose: { type: Boolean, default: false }
+	split: { type: Boolean, default: false }
 });
 
-const emit = defineEmits(["close"]);
-
 const christmas = ref(false);
 
 const { closeCurrentModal } = useModalsStore();
 
-const closeCurrentModalClick = () => {
-	if (props.interceptClose) emit("close");
-	else closeCurrentModal();
-};
-
 onMounted(async () => {
 	christmas.value = await lofig.get("siteSettings.christmas");
 });
@@ -31,7 +23,7 @@ onMounted(async () => {
 
 <template>
 	<div class="modal is-active">
-		<div class="modal-background" @click="closeCurrentModalClick()" />
+		<div class="modal-background" @click="closeCurrentModal()" />
 		<slot name="sidebar" />
 		<div
 			:class="{
@@ -46,9 +38,7 @@ onMounted(async () => {
 				<h2 class="modal-card-title is-marginless">
 					{{ title }}
 				</h2>
-				<span
-					class="delete material-icons"
-					@click="closeCurrentModalClick()"
+				<span class="delete material-icons" @click="closeCurrentModal()"
 					>highlight_off</span
 				>
 				<christmas-lights v-if="christmas" small :lights="5" />

+ 0 - 4
frontend/src/components/ReportInfoItem.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { defineAsyncComponent } from "vue";
 import { formatDistance } from "date-fns";
-import { useModalsStore } from "@/stores/modals";
 
 const ProfilePicture = defineAsyncComponent(
 	() => import("@/components/ProfilePicture.vue")
@@ -11,8 +10,6 @@ defineProps({
 	createdBy: { type: Object, default: () => {} },
 	createdAt: { type: String, default: "" }
 });
-
-const { closeModal } = useModalsStore();
 </script>
 
 <template>
@@ -40,7 +37,6 @@ const { closeModal } = useModalsStore();
 						path: `/u/${createdBy.username}`
 					}"
 					:title="createdBy._id"
-					@click="closeModal('viewReport')"
 				>
 					{{ createdBy.username }}
 				</router-link>

+ 58 - 61
frontend/src/components/modals/EditNews.vue

@@ -8,6 +8,7 @@ import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useEditNewsStore } from "@/stores/editNews";
 import { useModalsStore } from "@/stores/modals";
+import { useForm } from "@/composables/useForm";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const SaveButton = defineAsyncComponent(
@@ -28,11 +29,6 @@ const { createNews, newsId } = storeToRefs(editNewsStore);
 
 const { closeCurrentModal } = useModalsStore();
 
-const markdown = ref(
-	"# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n"
-);
-const status = ref("published");
-const showToNewUsers = ref(false);
 const createdBy = ref();
 const createdAt = ref(0);
 
@@ -58,54 +54,50 @@ const getTitle = () => {
 	return title;
 };
 
-const create = close => {
-	if (markdown.value === "") return new Toast("News item cannot be empty.");
-
-	const title = getTitle();
-	if (!title)
-		return new Toast(
-			"Please provide a title (heading level 1) at the top of the document."
-		);
-
-	return socket.dispatch(
-		"news.create",
-		{
-			title,
-			markdown: markdown.value,
-			status: status.value,
-			showToNewUsers: showToNewUsers.value
-		},
-		res => {
-			new Toast(res.message);
-			if (res.status === "success" && close) closeCurrentModal();
-		}
-	);
-};
-
-const update = close => {
-	if (markdown.value === "") return new Toast("News item cannot be empty.");
-
-	const title = getTitle();
-	if (!title)
-		return new Toast(
-			"Please provide a title (heading level 1) at the top of the document."
-		);
-
-	return socket.dispatch(
-		"news.update",
-		newsId.value,
-		{
-			title,
-			markdown: markdown.value,
-			status: status.value,
-			showToNewUsers: showToNewUsers.value
+const { inputs, save, setOriginalValue } = useForm(
+	{
+		markdown: {
+			value: "# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n",
+			validate: value => {
+				if (value === "") {
+					const err = "News item cannot be empty.";
+					new Toast(err);
+					return err;
+				}
+				if (!getTitle()) {
+					const err =
+						"Please provide a title (heading level 1) at the top of the document.";
+					new Toast(err);
+					return err;
+				}
+				return true;
+			}
 		},
-		res => {
-			new Toast(res.message);
-			if (res.status === "success" && close) closeCurrentModal();
-		}
-	);
-};
+		status: "published",
+		showToNewUsers: false
+	},
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success") {
+				const data = {
+					title: getTitle(),
+					markdown: values.markdown,
+					status: values.status,
+					showToNewUsers: values.showToNewUsers
+				};
+				const cb = res => {
+					new Toast(res.message);
+					if (res.status === "success") resolve();
+					else reject(new Error(res.message));
+				};
+				if (createNews.value) socket.dispatch("news.create", data, cb);
+				else socket.dispatch("news.update", newsId.value, data, cb);
+			} else if (status === "unchanged") new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid
+	}
+);
 
 onBeforeUnmount(() => {
 	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
@@ -128,9 +120,12 @@ onMounted(() => {
 		if (newsId.value && !createNews.value) {
 			socket.dispatch(`news.getNewsFromId`, newsId.value, res => {
 				if (res.status === "success") {
-					markdown.value = res.data.news.markdown;
-					status.value = res.data.news.status;
-					showToNewUsers.value = res.data.news.showToNewUsers;
+					setOriginalValue("markdown", res.data.news.markdown);
+					setOriginalValue("status", res.data.news.status);
+					setOriginalValue(
+						"showToNewUsers",
+						res.data.news.showToNewUsers
+					);
 					createdBy.value = res.data.news.createdBy;
 					createdAt.value = res.data.news.createdAt;
 				} else {
@@ -153,21 +148,23 @@ onMounted(() => {
 		<template #body>
 			<div class="left-section">
 				<p><strong>Markdown</strong></p>
-				<textarea v-model="markdown"></textarea>
+				<textarea v-model="inputs['markdown'].value"></textarea>
 			</div>
 			<div class="right-section">
 				<p><strong>Preview</strong></p>
 				<div
 					class="news-item"
 					id="preview"
-					v-html="DOMPurify.sanitize(marked(markdown))"
+					v-html="
+						DOMPurify.sanitize(marked(inputs['markdown'].value))
+					"
 				></div>
 			</div>
 		</template>
 		<template #footer>
 			<div>
 				<p class="control select">
-					<select v-model="status">
+					<select v-model="inputs['status'].value">
 						<option value="draft">Draft</option>
 						<option value="published" selected>Publish</option>
 					</select>
@@ -178,7 +175,7 @@ onMounted(() => {
 						<input
 							type="checkbox"
 							id="show-to-new-users"
-							v-model="showToNewUsers"
+							v-model="inputs['showToNewUsers'].value"
 						/>
 						<span class="slider round"></span>
 					</label>
@@ -191,13 +188,13 @@ onMounted(() => {
 				<save-button
 					ref="saveButton"
 					v-if="createNews"
-					@clicked="createNews ? create(false) : update(false)"
+					@clicked="save()"
 				/>
 
 				<save-button
 					ref="saveAndCloseButton"
 					default-message="Save and close"
-					@clicked="createNews ? create(true) : update(true)"
+					@clicked="save(closeCurrentModal)"
 				/>
 				<div class="right" v-if="createdAt > 0">
 					<span>

+ 104 - 37
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,10 +1,13 @@
 <script setup lang="ts">
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { onBeforeUnmount, onMounted, watch } from "vue";
 import validation from "@/validation";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
+import { useModalsStore } from "@/stores/modals";
+import { useForm } from "@/composables/useForm";
 
 const props = defineProps({
 	modalUuid: { type: String, default: "" }
@@ -19,6 +22,8 @@ const { socket } = useWebsocketsStore();
 const editPlaylistStore = useEditPlaylistStore(props);
 const { playlist } = storeToRefs(editPlaylistStore);
 
+const { preventCloseUnsaved } = useModalsStore();
+
 const isOwner = () =>
 	loggedIn.value && userId.value === playlist.value.createdBy;
 
@@ -31,40 +36,102 @@ const isEditable = permission =>
 		permission === "playlists.update.privacy" &&
 		hasPermission(permission));
 
-const renamePlaylist = () => {
-	const { displayName } = playlist.value;
-	if (!validation.isLength(displayName, 2, 32))
-		return new Toast("Display name must have between 2 and 32 characters.");
-	if (!validation.regex.ascii.test(displayName))
-		return new Toast(
-			"Invalid display name format. Only ASCII characters are allowed."
-		);
-
-	return socket.dispatch(
-		"playlists.updateDisplayName",
-		playlist.value._id,
-		playlist.value.displayName,
-		res => {
-			new Toast(res.message);
-		}
-	);
-};
-
-const updatePrivacy = () => {
-	const { privacy } = playlist.value;
-	if (privacy === "public" || privacy === "private") {
-		socket.dispatch(
-			playlist.value.type === "genre"
-				? "playlists.updatePrivacyAdmin"
-				: "playlists.updatePrivacy",
-			playlist.value._id,
-			privacy,
-			res => {
-				new Toast(res.message);
+const {
+	inputs: displayNameInputs,
+	unsavedChanges: displayNameUnsaved,
+	save: saveDisplayName,
+	setOriginalValue: setDisplayName
+} = useForm(
+	{
+		displayName: {
+			value: playlist.value.displayName,
+			validate: value => {
+				if (!validation.isLength(value, 2, 32)) {
+					const err =
+						"Display name must have between 2 and 32 characters.";
+					new Toast(err);
+					return err;
+				}
+				if (!validation.regex.ascii.test(value)) {
+					const err =
+						"Invalid display name format. Only ASCII characters are allowed.";
+					new Toast(err);
+					return err;
+				}
+				return true;
 			}
-		);
+		}
+	},
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success")
+				socket.dispatch(
+					"playlists.updateDisplayName",
+					playlist.value._id,
+					values.displayName,
+					res => {
+						playlist.value.displayName = values.displayName;
+						if (res.status === "success") {
+							resolve();
+							new Toast(res.message);
+						} else reject(new Error(res.message));
+					}
+				);
+			else new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
 	}
-};
+);
+
+const {
+	inputs: privacyInputs,
+	unsavedChanges: privacyUnsaved,
+	save: savePrivacy,
+	setOriginalValue: setPrivacy
+} = useForm(
+	{ privacy: playlist.value.privacy },
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success")
+				socket.dispatch(
+					playlist.value.type === "genre"
+						? "playlists.updatePrivacyAdmin"
+						: "playlists.updatePrivacy",
+					playlist.value._id,
+					values.privacy,
+					res => {
+						playlist.value.privacy = values.privacy;
+						if (res.status === "success") {
+							resolve();
+							new Toast(res.message);
+						} else reject(new Error(res.message));
+					}
+				);
+			else new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
+
+watch(playlist, (value, oldValue) => {
+	if (value.displayName !== oldValue.displayName)
+		setDisplayName("displayName", value.displayName);
+	if (value.privacy !== oldValue.privacy)
+		setPrivacy("privacy", value.privacy);
+});
+
+onMounted(() => {
+	preventCloseUnsaved[props.modalUuid] = () =>
+		displayNameUnsaved.value.length + privacyUnsaved.value.length > 0;
+});
+
+onBeforeUnmount(() => {
+	delete preventCloseUnsaved[props.modalUuid];
+});
 </script>
 
 <template>
@@ -83,17 +150,17 @@ const updatePrivacy = () => {
 			<div class="control is-grouped input-with-button">
 				<p class="control is-expanded">
 					<input
-						v-model="playlist.displayName"
+						v-model="displayNameInputs['displayName'].value"
 						class="input"
 						type="text"
 						placeholder="Playlist Display Name"
-						@keyup.enter="renamePlaylist()"
+						@keyup.enter="saveDisplayName()"
 					/>
 				</p>
 				<p class="control">
 					<button
 						class="button is-info"
-						@click.prevent="renamePlaylist()"
+						@click.prevent="saveDisplayName()"
 					>
 						Rename
 					</button>
@@ -105,7 +172,7 @@ const updatePrivacy = () => {
 			<label class="label"> Change privacy </label>
 			<div class="control is-grouped input-with-button">
 				<div class="control is-expanded select">
-					<select v-model="playlist.privacy">
+					<select v-model="privacyInputs['privacy'].value">
 						<option value="private">Private</option>
 						<option value="public">Public</option>
 					</select>
@@ -113,7 +180,7 @@ const updatePrivacy = () => {
 				<p class="control">
 					<button
 						class="button is-info"
-						@click.prevent="updatePrivacy()"
+						@click.prevent="savePrivacy()"
 					>
 						Update Privacy
 					</button>

+ 56 - 68
frontend/src/components/modals/EditSong/index.vue

@@ -57,9 +57,7 @@ const stationStore = useStationStore();
 const { socket } = useWebsocketsStore();
 const userAuthStore = useUserAuthStore();
 
-const modalsStore = useModalsStore();
-const { modals, activeModals } = storeToRefs(modalsStore);
-const { openModal } = modalsStore;
+const { openModal, closeCurrentModal, preventCloseCbs } = useModalsStore();
 const { hasPermission } = userAuthStore;
 
 const {
@@ -380,7 +378,7 @@ const loadSong = _youtubeId => {
 		} else {
 			new Toast("Song with that ID not found");
 			if (bulk.value) songNotFound.value = true;
-			if (!bulk.value) modalsStore.closeCurrentModal();
+			if (!bulk.value) closeCurrentModal();
 		}
 	});
 };
@@ -633,7 +631,7 @@ const save = (songToCopy, closeOrNext, saveButtonRefName, _newSong = false) => {
 			}
 
 			if (bulk.value) editNextSong();
-			else modalsStore.closeCurrentModal();
+			else closeCurrentModal();
 		});
 	return socket.dispatch(`songs.update`, _song._id, _song, res => {
 		new Toast(res.message);
@@ -652,7 +650,7 @@ const save = (songToCopy, closeOrNext, saveButtonRefName, _newSong = false) => {
 		if (!closeOrNext) return;
 
 		if (bulk.value) editNextSong();
-		else modalsStore.closeCurrentModal();
+		else closeCurrentModal();
 	});
 };
 
@@ -911,56 +909,57 @@ const confirmAction = ({ message, action, params }) => {
 	});
 };
 
-const onCloseModal = () => {
-	const songStringified = JSON.stringify({
-		...song.value,
-		...{
-			duration: Number(song.value.duration).toFixed(3)
-		}
-	});
-	const originalSongStringified = JSON.stringify({
-		...originalSong.value,
-		...{
-			duration: Number(originalSong.value.duration).toFixed(3)
-		}
-	});
-	const unsavedChanges = songStringified !== originalSongStringified;
-
-	const confirmReasons = [];
-
-	if (unsavedChanges) {
-		confirmReasons.push(
-			"You have unsaved changes. Are you sure you want to discard unsaved changes?"
-		);
-	}
+const onCloseModal = (): Promise<void> =>
+	new Promise(resolve => {
+		const songStringified = JSON.stringify({
+			...song.value,
+			...{
+				duration: Number(song.value.duration).toFixed(3)
+			}
+		});
+		const originalSongStringified = JSON.stringify({
+			...originalSong.value,
+			...{
+				duration: Number(originalSong.value.duration).toFixed(3)
+			}
+		});
+		const unsavedChanges = songStringified !== originalSongStringified;
 
-	if (bulk.value) {
-		const doneItems = items.value.filter(
-			item => item.status === "done"
-		).length;
-		const flaggedItems = items.value.filter(item => item.flagged).length;
-		const notDoneItems = items.value.length - doneItems;
+		const confirmReasons = [];
 
-		if (doneItems > 0 && notDoneItems > 0)
+		if (unsavedChanges) {
 			confirmReasons.push(
-				"You have songs which are not done yet. Are you sure you want to stop editing songs?"
+				"You have unsaved changes. Are you sure you want to discard unsaved changes?"
 			);
-		else if (flaggedItems > 0)
-			confirmReasons.push(
-				"You have songs which are flagged. Are you sure you want to stop editing songs?"
-			);
-	}
+		}
 
-	if (confirmReasons.length > 0) {
-		return confirmAction({
-			message: confirmReasons,
-			action: modalsStore.closeCurrentModal,
-			params: null
-		});
-	}
+		if (bulk.value) {
+			const doneItems = items.value.filter(
+				item => item.status === "done"
+			).length;
+			const flaggedItems = items.value.filter(
+				item => item.flagged
+			).length;
+			const notDoneItems = items.value.length - doneItems;
+
+			if (doneItems > 0 && notDoneItems > 0)
+				confirmReasons.push(
+					"You have songs which are not done yet. Are you sure you want to stop editing songs?"
+				);
+			else if (flaggedItems > 0)
+				confirmReasons.push(
+					"You have songs which are flagged. Are you sure you want to stop editing songs?"
+				);
+		}
 
-	return modalsStore.closeCurrentModal();
-};
+		if (confirmReasons.length > 0)
+			confirmAction({
+				message: confirmReasons,
+				action: resolve,
+				params: null
+			});
+		else resolve();
+	});
 
 watch(
 	() => song.value.duration,
@@ -978,11 +977,13 @@ watch(youtubeId, (_youtubeId, _oldYoutubeId) => {
 watch(
 	() => hasPermission("songs.update"),
 	value => {
-		if (!value) modalsStore.closeCurrentModal();
+		if (!value) closeCurrentModal();
 	}
 );
 
 onMounted(async () => {
+	preventCloseCbs[props.modalUuid] = onCloseModal;
+
 	activityWatchVideoDataInterval.value = setInterval(() => {
 		sendActivityWatchVideoData();
 	}, 1000);
@@ -1007,7 +1008,7 @@ onMounted(async () => {
 		} else if (youtubeId.value) loadSong(youtubeId.value);
 		else if (!bulk.value) {
 			new Toast("You can't open EditSong without editing a song");
-			return modalsStore.closeCurrentModal();
+			return closeCurrentModal();
 		}
 
 		interval.value = setInterval(() => {
@@ -1238,7 +1239,7 @@ onMounted(async () => {
 				youtubeIds.value,
 				res => {
 					if (res.data.songs.length === 0) {
-						modalsStore.closeCurrentModal();
+						closeCurrentModal();
 						new Toast("You can't edit 0 songs.");
 					} else {
 						items.value = res.data.songs.map(song => ({
@@ -1434,19 +1435,6 @@ onMounted(async () => {
 		}
 	});
 
-	keyboardShortcuts.registerShortcut("editSong.closeModal", {
-		keyCode: 27,
-		handler: () => {
-			if (
-				modals.value[
-					activeModals.value[activeModals.value.length - 1]
-				] === "editSong"
-			) {
-				onCloseModal();
-			}
-		}
-	});
-
 	/*
 
 	editSong.pauseResume - Num 5 - Pause/resume song
@@ -1508,6 +1496,8 @@ onBeforeUnmount(() => {
 		keyboardShortcuts.unregisterShortcut(shortcutName);
 	});
 
+	delete preventCloseCbs[props.modalUuid];
+
 	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
 	editSongStore.$dispose();
 });
@@ -1520,8 +1510,6 @@ onBeforeUnmount(() => {
 			class="song-modal"
 			:size="'wide'"
 			:split="true"
-			:intercept-close="true"
-			@close="onCloseModal"
 		>
 			<template #toggleMobileSidebar v-if="bulk">
 				<i

+ 155 - 116
frontend/src/components/modals/ManageStation/Settings.vue

@@ -1,10 +1,11 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted } from "vue";
+import { defineAsyncComponent, watch } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import validation from "@/validation";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useManageStationStore } from "@/stores/manageStation";
+import { useForm } from "@/composables/useForm";
 
 const InfoIcon = defineAsyncComponent(
 	() => import("@/components/InfoIcon.vue")
@@ -20,104 +21,132 @@ const manageStationStore = useManageStationStore(props);
 const { station } = storeToRefs(manageStationStore);
 const { editStation } = manageStationStore;
 
-const localStation = ref({
-	name: "",
-	displayName: "",
-	description: "",
-	theme: "blue",
-	privacy: "private",
-	requests: {
-		enabled: true,
-		access: "owner",
-		limit: 3
-	},
-	autofill: {
-		enabled: true,
-		limit: 30,
-		mode: "random"
-	}
-});
-
-const update = () => {
-	if (
-		JSON.stringify({
-			name: localStation.value.name,
-			displayName: localStation.value.displayName,
-			description: localStation.value.description,
-			theme: localStation.value.theme,
-			privacy: localStation.value.privacy,
-			requests: {
-				enabled: localStation.value.requests.enabled,
-				access: localStation.value.requests.access,
-				limit: localStation.value.requests.limit
-			},
-			autofill: {
-				enabled: localStation.value.autofill.enabled,
-				limit: localStation.value.autofill.limit,
-				mode: localStation.value.autofill.mode
+const { inputs, save, setOriginalValue } = useForm(
+	{
+		name: {
+			value: station.value.name,
+			validate: value => {
+				if (!validation.isLength(value, 2, 16)) {
+					const err = "Name must have between 2 and 16 characters.";
+					new Toast(err);
+					return err;
+				}
+				if (!validation.regex.az09_.test(value)) {
+					const err =
+						"Invalid name format. Allowed characters: a-z, 0-9 and _.";
+					new Toast(err);
+					return err;
+				}
+				return true;
+			}
+		},
+		displayName: {
+			value: station.value.displayName,
+			validate: value => {
+				if (!validation.isLength(value, 2, 32)) {
+					const err =
+						"Display name must have between 2 and 32 characters.";
+					new Toast(err);
+					return err;
+				}
+				if (!validation.regex.ascii.test(value)) {
+					const err =
+						"Invalid display name format. Only ASCII characters are allowed.";
+					new Toast(err);
+					return err;
+				}
+				return true;
 			}
-		}) !==
-		JSON.stringify({
-			name: station.value.name,
-			displayName: station.value.displayName,
-			description: station.value.description,
-			theme: station.value.theme,
-			privacy: station.value.privacy,
-			requests: {
-				enabled: station.value.requests.enabled,
-				access: station.value.requests.access,
-				limit: station.value.requests.limit
-			},
-			autofill: {
-				enabled: station.value.autofill.enabled,
-				limit: station.value.autofill.limit,
-				mode: station.value.autofill.mode
+		},
+		description: {
+			value: station.value.description,
+			validate: value => {
+				if (
+					value
+						.split("")
+						.filter(character => character.charCodeAt(0) === 21328)
+						.length !== 0
+				) {
+					const err = "Invalid description format.";
+					new Toast(err);
+					return err;
+				}
+				return true;
 			}
-		})
-	) {
-		const { name, displayName, description } = localStation.value;
-
-		if (!validation.isLength(name, 2, 16))
-			new Toast("Name must have between 2 and 16 characters.");
-		else if (!validation.regex.az09_.test(name))
-			new Toast(
-				"Invalid name format. Allowed characters: a-z, 0-9 and _."
-			);
-		else if (!validation.isLength(displayName, 2, 32))
-			new Toast("Display name must have between 2 and 32 characters.");
-		else if (!validation.regex.ascii.test(displayName))
-			new Toast(
-				"Invalid display name format. Only ASCII characters are allowed."
-			);
-		else if (!validation.isLength(description, 2, 200))
-			new Toast("Description must have between 2 and 200 characters.");
-		else if (
-			description
-				.split("")
-				.filter(character => character.charCodeAt(0) === 21328)
-				.length !== 0
-		)
-			new Toast("Invalid description format.");
-		else
-			socket.dispatch(
-				"stations.update",
-				station.value._id,
-				localStation.value,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						editStation(localStation.value);
+		},
+		theme: station.value.theme,
+		privacy: station.value.privacy,
+		requestsEnabled: station.value.requests.enabled,
+		requestsAccess: station.value.requests.access,
+		requestsLimit: station.value.requests.limit,
+		autofillEnabled: station.value.autofill.enabled,
+		autofillLimit: station.value.autofill.limit,
+		autofillMode: station.value.autofill.mode
+	},
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success") {
+				const oldStation = JSON.parse(JSON.stringify(station.value));
+				const updatedStation = {
+					...oldStation,
+					name: values.name,
+					displayName: values.displayName,
+					description: values.description,
+					theme: values.theme,
+					privacy: values.privacy,
+					requests: {
+						...oldStation.requests,
+						enabled: values.requestsEnabled,
+						access: values.requestsAccess,
+						limit: values.requestsLimit
+					},
+					autofill: {
+						...oldStation.autofill,
+						enabled: values.autofillEnabled,
+						limit: values.autofillLimit,
+						mode: values.autofillMode
 					}
-				}
-			);
-	} else {
-		new Toast("Please make a change before saving.");
+				};
+				socket.dispatch(
+					"stations.update",
+					station.value._id,
+					updatedStation,
+					res => {
+						new Toast(res.message);
+						if (res.status === "success") {
+							editStation(updatedStation);
+							resolve();
+						} else reject(new Error(res.message));
+					}
+				);
+			} else new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid
 	}
-};
+);
 
-onMounted(() => {
-	localStation.value = JSON.parse(JSON.stringify(station.value));
+watch(station, (value, oldValue) => {
+	if (value.name !== oldValue.name) setOriginalValue("name", value.name);
+	if (value.displayName !== oldValue.displayName)
+		setOriginalValue("displayName", value.displayName);
+	if (value.description !== oldValue.description)
+		setOriginalValue("description", value.description);
+	if (value.theme !== oldValue.theme) setOriginalValue("theme", value.theme);
+	if (value.privacy !== oldValue.privacy)
+		setOriginalValue("privacy", value.privacy);
+	if (value.requests.enabled !== oldValue.requests.enabled)
+		setOriginalValue("requestsEnabled", value.requests.enabled);
+	if (value.requests.access !== oldValue.requests.access)
+		setOriginalValue("requestsAccess", value.requests.access);
+	if (value.requests.limit !== oldValue.requests.limit)
+		setOriginalValue("requestsLimit", value.requests.limit);
+	if (value.autofill.enabled !== oldValue.autofill.enabled)
+		setOriginalValue("autofillEnabled", value.autofill.enabled);
+	if (value.autofill.limit !== oldValue.autofill.limit)
+		setOriginalValue("autofillLimit", value.autofill.limit);
+	if (value.autofill.mode !== oldValue.autofill.mode)
+		setOriginalValue("autofillMode", value.autofill.mode);
 });
 </script>
 
@@ -125,7 +154,7 @@ onMounted(() => {
 	<div class="station-settings">
 		<label class="label">Name</label>
 		<div class="control is-expanded">
-			<input class="input" type="text" v-model="localStation.name" />
+			<input class="input" type="text" v-model="inputs['name'].value" />
 		</div>
 
 		<label class="label">Display Name</label>
@@ -133,7 +162,7 @@ onMounted(() => {
 			<input
 				class="input"
 				type="text"
-				v-model="localStation.displayName"
+				v-model="inputs['displayName'].value"
 			/>
 		</div>
 
@@ -142,7 +171,7 @@ onMounted(() => {
 			<input
 				class="input"
 				type="text"
-				v-model="localStation.description"
+				v-model="inputs['description'].value"
 			/>
 		</div>
 
@@ -150,7 +179,7 @@ onMounted(() => {
 			<div class="small-section">
 				<label class="label">Theme</label>
 				<div class="control is-expanded select">
-					<select v-model="localStation.theme">
+					<select v-model="inputs['theme'].value">
 						<option value="blue" selected>Blue</option>
 						<option value="purple">Purple</option>
 						<option value="teal">Teal</option>
@@ -163,7 +192,7 @@ onMounted(() => {
 			<div class="small-section">
 				<label class="label">Privacy</label>
 				<div class="control is-expanded select">
-					<select v-model="localStation.privacy">
+					<select v-model="inputs['privacy'].value">
 						<option value="public">Public</option>
 						<option value="unlisted">Unlisted</option>
 						<option value="private" selected>Private</option>
@@ -172,9 +201,8 @@ onMounted(() => {
 			</div>
 
 			<div
-				v-if="localStation.requests"
 				class="requests-settings"
-				:class="{ enabled: localStation.requests.enabled }"
+				:class="{ enabled: inputs['requestsEnabled'].value }"
 			>
 				<div class="toggle-row">
 					<label class="label">
@@ -188,7 +216,7 @@ onMounted(() => {
 							<input
 								type="checkbox"
 								id="toggle-requests"
-								v-model="localStation.requests.enabled"
+								v-model="inputs['requestsEnabled'].value"
 							/>
 							<span class="slider round"></span>
 						</label>
@@ -196,7 +224,7 @@ onMounted(() => {
 						<label for="toggle-requests">
 							<p>
 								{{
-									localStation.requests.enabled
+									inputs["requestsEnabled"].value
 										? "Enabled"
 										: "Disabled"
 								}}
@@ -205,17 +233,23 @@ onMounted(() => {
 					</p>
 				</div>
 
-				<div v-if="localStation.requests.enabled" class="small-section">
+				<div
+					v-if="inputs['requestsEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Minimum access</label>
 					<div class="control is-expanded select">
-						<select v-model="localStation.requests.access">
+						<select v-model="inputs['requestsAccess'].value">
 							<option value="owner" selected>Owner</option>
 							<option value="user">User</option>
 						</select>
 					</div>
 				</div>
 
-				<div v-if="localStation.requests.enabled" class="small-section">
+				<div
+					v-if="inputs['requestsEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Per user request limit</label>
 					<div class="control is-expanded">
 						<input
@@ -223,16 +257,15 @@ onMounted(() => {
 							type="number"
 							min="1"
 							max="50"
-							v-model="localStation.requests.limit"
+							v-model="inputs['requestsLimit'].value"
 						/>
 					</div>
 				</div>
 			</div>
 
 			<div
-				v-if="localStation.autofill"
 				class="autofill-settings"
-				:class="{ enabled: localStation.autofill.enabled }"
+				:class="{ enabled: inputs['autofillEnabled'].value }"
 			>
 				<div class="toggle-row">
 					<label class="label">
@@ -246,7 +279,7 @@ onMounted(() => {
 							<input
 								type="checkbox"
 								id="toggle-autofill"
-								v-model="localStation.autofill.enabled"
+								v-model="inputs['autofillEnabled'].value"
 							/>
 							<span class="slider round"></span>
 						</label>
@@ -254,7 +287,7 @@ onMounted(() => {
 						<label for="toggle-autofill">
 							<p>
 								{{
-									localStation.autofill.enabled
+									inputs["autofillEnabled"].value
 										? "Enabled"
 										: "Disabled"
 								}}
@@ -263,7 +296,10 @@ onMounted(() => {
 					</p>
 				</div>
 
-				<div v-if="localStation.autofill.enabled" class="small-section">
+				<div
+					v-if="inputs['autofillEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Song limit</label>
 					<div class="control is-expanded">
 						<input
@@ -271,15 +307,18 @@ onMounted(() => {
 							type="number"
 							min="1"
 							max="50"
-							v-model="localStation.autofill.limit"
+							v-model="inputs['autofillLimit'].value"
 						/>
 					</div>
 				</div>
 
-				<div v-if="localStation.autofill.enabled" class="small-section">
+				<div
+					v-if="inputs['autofillEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Play mode</label>
 					<div class="control is-expanded select">
-						<select v-model="localStation.autofill.mode">
+						<select v-model="inputs['autofillMode'].value">
 							<option value="random" selected>Random</option>
 							<option value="sequential">Sequential</option>
 						</select>
@@ -288,7 +327,7 @@ onMounted(() => {
 			</div>
 		</div>
 
-		<button class="control is-expanded button is-primary" @click="update()">
+		<button class="control is-expanded button is-primary" @click="save()">
 			Save Changes
 		</button>
 	</div>

+ 171 - 0
frontend/src/composables/useForm.ts

@@ -0,0 +1,171 @@
+import { ref, computed, onMounted, onBeforeUnmount } from "vue";
+import { useModalsStore } from "@/stores/modals";
+
+export const useForm = (
+	inputOptions: {
+		[key: string]:
+			| {
+					value: any;
+					validate?: (value: any) => boolean | string;
+			  }
+			| any;
+	},
+	cb: (
+		status: string,
+		message: string,
+		values: { [key: string]: any }
+	) => Promise<void>,
+	options?: {
+		modalUuid?: string;
+		preventCloseUnsaved?: boolean;
+	}
+) => {
+	const { openModal, preventCloseUnsaved } = useModalsStore();
+
+	const inputs = ref(
+		Object.fromEntries(
+			Object.entries(inputOptions).map(([name, input]) => {
+				if (typeof input !== "object") input = { value: input };
+				return [
+					name,
+					{
+						...input,
+						originalValue: input.value,
+						errors: <string[]>[],
+						ref: ref(),
+						sourceChanged: false
+					}
+				];
+			})
+		)
+	);
+
+	const unsavedChanges = computed(() => {
+		const changed = <string[]>[];
+		Object.entries(inputs.value).forEach(([name, input]) => {
+			if (
+				JSON.stringify(input.value) !==
+				JSON.stringify(input.originalValue)
+			)
+				changed.push(name);
+		});
+		return changed;
+	});
+
+	const sourceChanged = computed(() => {
+		const _sourceChanged = <string[]>[];
+		Object.entries(inputs.value).forEach(([name, input]) => {
+			if (input.sourceChanged) _sourceChanged.push(name);
+		});
+		return _sourceChanged;
+	});
+
+	const useCallback = (status: string, message?: string) =>
+		cb(
+			status,
+			message || status,
+			Object.fromEntries(
+				Object.entries(inputs.value).map(([name, input]) => [
+					name,
+					input.value
+				])
+			)
+		);
+
+	const resetOriginalValues = () => {
+		inputs.value = Object.fromEntries(
+			Object.entries(inputs.value).map(([name, input]) => [
+				name,
+				{
+					...input,
+					originalValue: input.value,
+					sourceChanged: false
+				}
+			])
+		);
+	};
+
+	const validate = () => {
+		const invalid = <string[]>[];
+		Object.entries(inputs.value).forEach(([name, input]) => {
+			input.errors = [];
+			if (input.validate) {
+				const valid = input.validate(input.value);
+				if (valid !== true) {
+					invalid.push(name);
+					input.errors.push(
+						valid === false ? `Invalid ${name}` : valid
+					);
+				}
+			}
+		});
+		return invalid;
+	};
+
+	const save = (saveCb?: () => void) => {
+		const invalid = validate();
+		if (invalid.length === 0 && unsavedChanges.value.length > 0) {
+			const onSave = () => {
+				useCallback("success")
+					.then(() => {
+						resetOriginalValues();
+						if (saveCb) saveCb();
+					})
+					.catch((err: Error) => useCallback("error", err.message));
+			};
+			if (sourceChanged.value.length > 0)
+				openModal({
+					modal: "confirm",
+					data: {
+						message:
+							"Updates have been made whilst you were making changes. Are you sure you want to continue?",
+						onCompleted: onSave
+					}
+				});
+			else onSave();
+		} else if (invalid.length === 0) {
+			useCallback("unchanged", "No changes to update");
+			if (saveCb) saveCb();
+		} else {
+			useCallback("error", `${invalid.length} inputs failed validation.`);
+		}
+	};
+
+	const setOriginalValue = (input: string, value: any) => {
+		if (
+			JSON.stringify(value) !==
+			JSON.stringify(inputs.value[input].originalValue)
+		) {
+			if (unsavedChanges.value.find(change => change === input))
+				inputs.value[input].sourceChanged = true;
+			else inputs.value[input].value = value;
+			inputs.value[input].originalValue = value;
+		}
+	};
+
+	onMounted(() => {
+		if (
+			options &&
+			options.modalUuid &&
+			options.preventCloseUnsaved !== false
+		)
+			preventCloseUnsaved[options.modalUuid] = () =>
+				unsavedChanges.value.length > 0;
+	});
+
+	onBeforeUnmount(() => {
+		if (
+			options &&
+			options.modalUuid &&
+			options.preventCloseUnsaved !== false
+		)
+			delete preventCloseUnsaved[options.modalUuid];
+	});
+
+	return {
+		inputs,
+		unsavedChanges,
+		save,
+		setOriginalValue
+	};
+};

+ 37 - 24
frontend/src/stores/modals.ts

@@ -23,24 +23,44 @@ import { useWhatIsNewStore } from "@/stores/whatIsNew";
 export const useModalsStore = defineStore("modals", {
 	state: () => ({
 		modals: {},
-		activeModals: []
+		activeModals: [],
+		preventCloseUnsaved: <{ [uuid: string]: () => boolean }>{},
+		preventCloseCbs: <{ [uuid: string]: () => Promise<void> }>{}
 	}),
 	actions: {
-		closeModal(modal) {
-			if (modal === "register")
-				lofig.get("recaptcha.enabled").then(enabled => {
-					if (enabled) window.location.reload();
-				});
-
-			Object.entries(this.modals).forEach(([uuid, _modal]) => {
-				if (modal === _modal) {
-					const { socket } = useWebsocketsStore();
-					socket.destroyModalListeners(uuid);
-					this.activeModals.splice(
-						this.activeModals.indexOf(uuid),
-						1
-					);
-					delete this.modals[uuid];
+		closeModal(uuid: string) {
+			Object.entries(this.modals).forEach(([_uuid, modal]) => {
+				if (uuid === _uuid) {
+					if (modal === "register")
+						lofig.get("recaptcha.enabled").then(enabled => {
+							if (enabled) window.location.reload();
+						});
+					const close = () => {
+						const { socket } = useWebsocketsStore();
+						socket.destroyModalListeners(uuid);
+						this.activeModals.splice(
+							this.activeModals.indexOf(uuid),
+							1
+						);
+						delete this.modals[uuid];
+					};
+					if (typeof this.preventCloseCbs[uuid] !== "undefined")
+						this.preventCloseCbs[uuid]().then(() => {
+							close();
+						});
+					else if (
+						typeof this.preventCloseUnsaved[uuid] !== "undefined" &&
+						this.preventCloseUnsaved[uuid]()
+					) {
+						this.openModal({
+							modal: "confirm",
+							data: {
+								message:
+									"You have unsaved changes. Are you sure you want to discard these changes and close the modal?",
+								onCompleted: close
+							}
+						});
+					} else close();
 				}
 			});
 		},
@@ -119,14 +139,7 @@ export const useModalsStore = defineStore("modals", {
 		closeCurrentModal() {
 			const currentlyActiveModalUuid =
 				this.activeModals[this.activeModals.length - 1];
-			// TODO: make sure to only destroy/register modal listeners for a unique modal
-			// remove any websocket listeners for the modal
-			const { socket } = useWebsocketsStore();
-			socket.destroyModalListeners(currentlyActiveModalUuid);
-
-			this.activeModals.pop();
-
-			delete this.modals[currentlyActiveModalUuid];
+			this.closeModal(currentlyActiveModalUuid);
 		},
 		closeAllModals() {
 			const { socket } = useWebsocketsStore();