瀏覽代碼

refactor: Continued editSong form changes

Owen Diffey 2 年之前
父節點
當前提交
3e6707ec54

+ 9 - 13
frontend/src/components/modals/EditNews.vue

@@ -59,24 +59,16 @@ 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;
-				}
+				if (value === "") return "News item cannot be empty.";
+				if (!getTitle())
+					return "Please provide a title (heading level 1) at the top of the document.";
 				return true;
 			}
 		},
 		status: "published",
 		showToNewUsers: false
 	},
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success") {
 				const data = {
@@ -92,7 +84,11 @@ const { inputs, save, setOriginalValue } = useForm(
 				};
 				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);
+			} else if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
 		}),
 	{
 		modalUuid: props.modalUuid

+ 11 - 16
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -46,23 +46,15 @@ const {
 		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;
-				}
+				if (!validation.isLength(value, 2, 32))
+					return "Display name must have between 2 and 32 characters.";
+				if (!validation.regex.ascii.test(value))
+					return "Invalid display name format. Only ASCII characters are allowed.";
 				return true;
 			}
 		}
 	},
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success")
 				socket.dispatch(
@@ -77,7 +69,10 @@ const {
 						} else reject(new Error(res.message));
 					}
 				);
-			else new Toast(message);
+			else
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
 		}),
 	{
 		modalUuid: props.modalUuid,
@@ -92,7 +87,7 @@ const {
 	setOriginalValue: setPrivacy
 } = useForm(
 	{ privacy: playlist.value.privacy },
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success")
 				socket.dispatch(
@@ -109,7 +104,7 @@ const {
 						} else reject(new Error(res.message));
 					}
 				);
-			else new Toast(message);
+			else if (messages[status]) new Toast(messages[status]);
 		}),
 	{
 		modalUuid: props.modalUuid,

+ 26 - 15
frontend/src/components/modals/EditSong/Tabs/Discogs.vue

@@ -21,7 +21,7 @@ const editSongStore = useEditSongStore(props);
 
 const { socket } = useWebsocketsStore();
 
-const { song } = storeToRefs(editSongStore);
+const { form } = storeToRefs(editSongStore);
 
 const { selectDiscogsInfo } = editSongStore;
 
@@ -136,7 +136,7 @@ const selectTrack = (apiResultIndex, trackIndex) => {
 };
 
 onMounted(() => {
-	discogsQuery.value = song.value.title;
+	discogsQuery.value = form.value.inputs.title.value;
 
 	keyboardShortcuts.registerShortcut("editSong.focusDiscogs", {
 		keyCode: 35,
@@ -150,21 +150,25 @@ onMounted(() => {
 
 <template>
 	<div class="discogs-tab">
-		<div class="selected-discogs-info" v-if="!song.discogs">
-			<p class="selected-discogs-info-none">None</p>
-		</div>
-		<div class="selected-discogs-info" v-if="song.discogs">
+		<div
+			class="selected-discogs-info"
+			v-if="form.inputs.discogs.value && form.inputs.discogs.value.album"
+		>
 			<div class="top-container">
-				<img :src="song.discogs.album.albumArt" />
+				<img :src="form.inputs.discogs.value.album.albumArt" />
 				<div class="right-container">
 					<p class="album-title">
-						{{ song.discogs.album.title }}
+						{{ form.inputs.discogs.value.album.title }}
 					</p>
 					<div class="bottom-row">
 						<p class="type-year">
-							<span>{{ song.discogs.album.type }}</span>
+							<span>{{
+								form.inputs.discogs.value.album.type
+							}}</span>
-							<span>{{ song.discogs.album.year }}</span>
+							<span>{{
+								form.inputs.discogs.value.album.year
+							}}</span>
 						</p>
 					</div>
 				</div>
@@ -172,25 +176,32 @@ onMounted(() => {
 			<div class="bottom-container">
 				<p class="bottom-container-field">
 					Artists:
-					<span>{{ song.discogs.album.artists.join(", ") }}</span>
+					<span>{{
+						form.inputs.discogs.value.album.artists.join(", ")
+					}}</span>
 				</p>
 				<p class="bottom-container-field">
 					Genres:
-					<span>{{ song.discogs.album.genres.join(", ") }}</span>
+					<span>{{
+						form.inputs.discogs.value.album.genres.join(", ")
+					}}</span>
 				</p>
 				<p class="bottom-container-field">
 					Data quality:
-					<span>{{ song.discogs.dataQuality }}</span>
+					<span>{{ form.inputs.discogs.value.dataQuality }}</span>
 				</p>
 				<p class="bottom-container-field">
 					Track:
 					<span
-						>{{ song.discogs.track.position }}.
-						{{ song.discogs.track.title }}</span
+						>{{ form.inputs.discogs.value.track.position }}.
+						{{ form.inputs.discogs.value.track.title }}</span
 					>
 				</p>
 			</div>
 		</div>
+		<div class="selected-discogs-info" v-else>
+			<p class="selected-discogs-info-none">None</p>
+		</div>
 
 		<label class="label"> Search for a song from Discogs </label>
 		<div class="control is-grouped input-with-button">

+ 2 - 6
frontend/src/components/modals/EditSong/Tabs/Songs.vue

@@ -1,8 +1,6 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, onMounted } from "vue";
 
-import { storeToRefs } from "pinia";
-
 import { useEditSongStore } from "@/stores/editSong";
 
 import { useSearchMusare } from "@/composables/useSearchMusare";
@@ -18,9 +16,7 @@ const props = defineProps({
 
 const sitename = ref("Musare");
 
-const editSongStore = useEditSongStore(props);
-
-const { song } = storeToRefs(editSongStore);
+const { form } = useEditSongStore(props);
 
 const {
 	musareSearch,
@@ -32,7 +28,7 @@ const {
 onMounted(async () => {
 	sitename.value = await lofig.get("siteSettings.sitename");
 
-	musareSearch.value.query = song.value.title;
+	musareSearch.value.query = form.inputs.title.value;
 	searchForMusareSongs(1, false);
 });
 </script>

+ 8 - 7
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -14,19 +14,20 @@ const props = defineProps({
 
 const editSongStore = useEditSongStore(props);
 
-const { song, newSong } = storeToRefs(editSongStore);
+const { form, newSong } = storeToRefs(editSongStore);
 
-const { updateYoutubeId, updateTitle, updateThumbnail } = editSongStore;
+const { updateYoutubeId } = editSongStore;
 
 const { youtubeSearch, searchForSongs, loadMoreSongs } = useSearchYoutube();
 
 const selectSong = result => {
 	updateYoutubeId(result.id);
 
-	if (newSong) {
-		updateTitle(result.title);
-		updateThumbnail(result.thumbnail);
-	}
+	if (newSong)
+		form.value.setValue({
+			title: result.title,
+			thumbnail: result.thumbnail
+		});
 };
 </script>
 
@@ -66,7 +67,7 @@ const selectSong = result => {
 				<template #actions>
 					<i
 						class="material-icons icon-selected"
-						v-if="result.id === song.youtubeId"
+						v-if="result.id === form.inputs.youtubeId.value"
 						key="selected"
 						>radio_button_checked
 					</i>

File diff suppressed because it is too large
+ 363 - 315
frontend/src/components/modals/EditSong/index.vue


+ 32 - 41
frontend/src/components/modals/EditUser.vue

@@ -39,23 +39,15 @@ const {
 		username: {
 			value: user.value.username,
 			validate: value => {
-				if (!validation.isLength(value, 2, 32)) {
-					const err =
-						"Username must have between 2 and 32 characters.";
-					new Toast(err);
-					return err;
-				}
-				if (!validation.regex.custom("a-zA-Z0-9_-").test(value)) {
-					const err =
-						"Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -.";
-					new Toast(err);
-					return err;
-				}
+				if (!validation.isLength(value, 2, 32))
+					return "Username must have between 2 and 32 characters.";
+				if (!validation.regex.custom("a-zA-Z0-9_-").test(value))
+					return "Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -.";
 				return true;
 			}
 		}
 	},
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success")
 				socket.dispatch(
@@ -70,7 +62,10 @@ const {
 						} else reject(new Error(res.message));
 					}
 				);
-			else new Toast(message);
+			else
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
 		}),
 	{
 		modalUuid: props.modalUuid,
@@ -88,25 +83,19 @@ const {
 		email: {
 			value: "",
 			validate: value => {
-				if (!validation.isLength(value, 3, 254)) {
-					const err = "Email must have between 3 and 254 characters.";
-					new Toast(err);
-					return err;
-				}
+				if (!validation.isLength(value, 3, 254))
+					return "Email must have between 3 and 254 characters.";
 				if (
 					value.indexOf("@") !== value.lastIndexOf("@") ||
 					!validation.regex.emailSimple.test(value) ||
 					!validation.regex.ascii.test(value)
-				) {
-					const err = "Invalid email format.";
-					new Toast(err);
-					return err;
-				}
+				)
+					return "Invalid email format.";
 				return true;
 			}
 		}
 	},
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success")
 				socket.dispatch(
@@ -121,7 +110,10 @@ const {
 						} else reject(new Error(res.message));
 					}
 				);
-			else new Toast(message);
+			else
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
 		}),
 	{
 		modalUuid: props.modalUuid,
@@ -136,7 +128,7 @@ const {
 	setOriginalValue: setRole
 } = useForm(
 	{ role: user.value.role },
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success")
 				socket.dispatch(
@@ -151,7 +143,10 @@ const {
 						} else reject(new Error(res.message));
 					}
 				);
-			else new Toast(message);
+			else
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
 		}),
 	{
 		modalUuid: props.modalUuid,
@@ -168,23 +163,16 @@ const {
 		reason: {
 			value: "",
 			validate: value => {
-				if (!validation.isLength(value, 1, 64)) {
-					const err = "Reason must have between 1 and 64 characters.";
-					new Toast(err);
-					return err;
-				}
-				if (!validation.regex.ascii.test(value)) {
-					const err =
-						"Invalid reason format. Only ascii characters are allowed.";
-					new Toast(err);
-					return err;
-				}
+				if (!validation.isLength(value, 1, 64))
+					return "Reason must have between 1 and 64 characters.";
+				if (!validation.regex.ascii.test(value))
+					return "Invalid reason format. Only ascii characters are allowed.";
 				return true;
 			}
 		},
 		expiresAt: "1h"
 	},
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success")
 				socket.dispatch(
@@ -198,7 +186,10 @@ const {
 						else reject(new Error(res.message));
 					}
 				);
-			else if (status === "unchanged") new Toast(message);
+			else
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
 		}),
 	{
 		modalUuid: props.modalUuid,

+ 15 - 30
frontend/src/components/modals/ManageStation/Settings.vue

@@ -26,35 +26,20 @@ 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;
-				}
+				if (!validation.isLength(value, 2, 16))
+					return "Name must have between 2 and 16 characters.";
+				if (!validation.regex.az09_.test(value))
+					return "Invalid name format. Allowed characters: a-z, 0-9 and _.";
 				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;
-				}
+				if (!validation.isLength(value, 2, 32))
+					return "Display name must have between 2 and 32 characters.";
+				if (!validation.regex.ascii.test(value))
+					return "Invalid display name format. Only ASCII characters are allowed.";
 				return true;
 			}
 		},
@@ -66,11 +51,8 @@ const { inputs, save, setOriginalValue } = useForm(
 						.split("")
 						.filter(character => character.charCodeAt(0) === 21328)
 						.length !== 0
-				) {
-					const err = "Invalid description format.";
-					new Toast(err);
-					return err;
-				}
+				)
+					return "Invalid description format.";
 				return true;
 			}
 		},
@@ -83,7 +65,7 @@ const { inputs, save, setOriginalValue } = useForm(
 		autofillLimit: station.value.autofill.limit,
 		autofillMode: station.value.autofill.mode
 	},
-	(status, message, values) =>
+	(status, messages, values) =>
 		new Promise((resolve, reject) => {
 			if (status === "success") {
 				const oldStation = JSON.parse(JSON.stringify(station.value));
@@ -119,7 +101,10 @@ const { inputs, save, setOriginalValue } = useForm(
 						} else reject(new Error(res.message));
 					}
 				);
-			} else new Toast(message);
+			} else
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
 		}),
 	{
 		modalUuid: props.modalUuid

+ 58 - 20
frontend/src/composables/useForm.ts

@@ -12,7 +12,7 @@ export const useForm = (
 	},
 	cb: (
 		status: string,
-		message: string,
+		messages: { [key: string]: string },
 		values: { [key: string]: any }
 	) => Promise<void>,
 	options?: {
@@ -33,7 +33,10 @@ export const useForm = (
 						originalValue: input.value,
 						errors: <string[]>[],
 						ref: ref(),
-						sourceChanged: false
+						sourceChanged: false,
+						ignoreUnsaved: input.ignoreUnsaved === true,
+						required:
+							input.required === undefined ? true : input.required
 					}
 				];
 			})
@@ -44,8 +47,9 @@ export const useForm = (
 		const changed = <string[]>[];
 		Object.entries(inputs.value).forEach(([name, input]) => {
 			if (
+				!input.ignoreUnsaved &&
 				JSON.stringify(input.value) !==
-				JSON.stringify(input.originalValue)
+					JSON.stringify(input.originalValue)
 			)
 				changed.push(name);
 		});
@@ -55,15 +59,22 @@ export const useForm = (
 	const sourceChanged = computed(() => {
 		const _sourceChanged = <string[]>[];
 		Object.entries(inputs.value).forEach(([name, input]) => {
-			if (input.sourceChanged) _sourceChanged.push(name);
+			if (
+				input.sourceChanged &&
+				unsavedChanges.value.find(change => change === name)
+			)
+				_sourceChanged.push(name);
 		});
 		return _sourceChanged;
 	});
 
-	const useCallback = (status: string, message?: string) =>
+	const useCallback = (
+		status: string,
+		messages?: { [key: string]: string }
+	) =>
 		cb(
 			status,
-			message || status,
+			{ ...messages },
 			Object.fromEntries(
 				Object.entries(inputs.value).map(([name, input]) => [
 					name,
@@ -86,32 +97,43 @@ export const useForm = (
 	};
 
 	const validate = () => {
-		const invalid = <string[]>[];
+		const invalid = <{ [key: string]: string[] }>{};
 		Object.entries(inputs.value).forEach(([name, input]) => {
 			input.errors = [];
+			if (
+				input.required &&
+				(input.value === undefined ||
+					input.value === "" ||
+					input.value === null)
+			)
+				input.errors.push(`Invalid ${name}. Please provide value`);
 			if (input.validate) {
 				const valid = input.validate(input.value);
 				if (valid !== true) {
-					invalid.push(name);
 					input.errors.push(
 						valid === false ? `Invalid ${name}` : valid
 					);
 				}
 			}
+			if (input.errors.length > 0)
+				invalid[name] = input.errors.join(", ");
 		});
 		return invalid;
 	};
 
 	const save = (saveCb?: () => void) => {
-		const invalid = validate();
-		if (invalid.length === 0 && unsavedChanges.value.length > 0) {
+		const errors = validate();
+		const errorCount = Object.keys(errors).length;
+		if (errorCount === 0 && unsavedChanges.value.length > 0) {
 			const onSave = () => {
 				useCallback("success")
 					.then(() => {
 						resetOriginalValues();
 						if (saveCb) saveCb();
 					})
-					.catch((err: Error) => useCallback("error", err.message));
+					.catch((err: Error) =>
+						useCallback("error", { error: err.message })
+					);
 			};
 			if (sourceChanged.value.length > 0)
 				openModal({
@@ -123,20 +145,31 @@ export const useForm = (
 					}
 				});
 			else onSave();
-		} else if (invalid.length === 0) {
-			useCallback("unchanged", "No changes to update");
+		} else if (errorCount === 0) {
+			useCallback("unchanged", { unchanged: "No changes to update" });
 			if (saveCb) saveCb();
 		} else {
-			useCallback("error", `${invalid.length} inputs failed validation.`);
+			useCallback("error", {
+				...errors,
+				error: `${errorCount} ${
+					errorCount === 1 ? "input" : "inputs"
+				} failed validation.`
+			});
 		}
 	};
 
-	const setValue = (value: { [key: string]: any }) => {
+	const setValue = (value: { [key: string]: any }, reset?: boolean) => {
 		Object.entries(value).forEach(([name, inputValue]) => {
 			if (inputs.value[name]) {
-				inputs.value[name].sourceChanged = false;
-				inputs.value[name].value = inputValue;
-				inputs.value[name].originalValue = inputValue;
+				inputs.value[name].value = JSON.parse(
+					JSON.stringify(inputValue)
+				);
+				if (reset) {
+					inputs.value[name].sourceChanged = false;
+					inputs.value[name].originalValue = JSON.parse(
+						JSON.stringify(inputValue)
+					);
+				}
 			}
 		});
 	};
@@ -150,8 +183,13 @@ export const useForm = (
 				) {
 					if (unsavedChanges.value.find(change => change === name))
 						inputs.value[name].sourceChanged = true;
-					else inputs.value[name].value = inputValue;
-					inputs.value[name].originalValue = inputValue;
+					else
+						inputs.value[name].value = JSON.parse(
+							JSON.stringify(inputValue)
+						);
+					inputs.value[name].originalValue = JSON.parse(
+						JSON.stringify(inputValue)
+					);
 				}
 			}
 		});

+ 37 - 18
frontend/src/stores/editSong.ts

@@ -34,12 +34,17 @@ export const useEditSongStore = ({ modalUuid }: { modalUuid: string }) =>
 									errors: string[];
 									ref: Ref;
 									sourceChanged: boolean;
+									required: boolean;
+									ignoreUnsaved: boolean;
 							  }
 							| any;
 					}>;
 					unsavedChanges: ComputedRef<string[]>;
 					save: (saveCb?: () => void) => void;
-					setValue: (value: { [key: string]: any }) => void;
+					setValue: (
+						value: { [key: string]: any },
+						reset?: boolean
+					) => void;
 					setOriginalValue: (value: { [key: string]: any }) => void;
 				}
 			>{}
@@ -77,18 +82,41 @@ export const useEditSongStore = ({ modalUuid }: { modalUuid: string }) =>
 					thumbnail: song.thumbnail,
 					youtubeId: song.youtubeId,
 					verified: song.verified,
+					addArtist: "",
 					artists: song.artists,
+					addGenre: "",
 					genres: song.genres,
-					tags: song.tags
+					addTag: "",
+					tags: song.tags,
+					discogs: song.discogs
 				};
-				if (reset && this.form.setValue) this.form.setValue(formSong);
-				else if (!reset && this.form.setOriginalValue)
-					this.form.setOriginalValue(formSong);
+				if (reset) this.form.setValue(formSong, true);
+				else this.form.setOriginalValue(formSong);
 			},
 			resetSong(youtubeId) {
 				if (this.youtubeId === youtubeId) this.youtubeId = "";
-				if (this.song && this.song.youtubeId === youtubeId)
+				if (this.song && this.song.youtubeId === youtubeId) {
 					this.song = {};
+					if (this.form.setValue)
+						this.form.setValue(
+							{
+								title: "",
+								duration: 0,
+								skipDuration: 0,
+								thumbnail: "",
+								youtubeId: "",
+								verified: false,
+								addArtist: "",
+								artists: [],
+								addGenre: "",
+								genres: [],
+								addTag: "",
+								tags: [],
+								discogs: {}
+							},
+							true
+						);
+				}
 			},
 			stopVideo() {
 				if (this.video.player && this.video.player.pauseVideo) {
@@ -102,7 +130,7 @@ export const useEditSongStore = ({ modalUuid }: { modalUuid: string }) =>
 				}
 			},
 			loadVideoById(id, skipDuration) {
-				this.song.duration = -1;
+				this.form.setValue({ duration: -1 });
 				this.video.player.loadVideoById(id, skipDuration);
 			},
 			pauseVideo(status) {
@@ -115,11 +143,8 @@ export const useEditSongStore = ({ modalUuid }: { modalUuid: string }) =>
 				}
 				this.video.paused = status;
 			},
-			updateSongField(data) {
-				this.song[data.field] = data.value;
-			},
 			selectDiscogsInfo(discogsInfo) {
-				this.song.discogs = discogsInfo;
+				this.form.setValue({ discogs: discogsInfo });
 			},
 			updateReports(reports) {
 				this.reports = reports;
@@ -130,15 +155,9 @@ export const useEditSongStore = ({ modalUuid }: { modalUuid: string }) =>
 				);
 			},
 			updateYoutubeId(youtubeId) {
-				this.song.youtubeId = youtubeId;
+				this.form.setValue({ youtubeId });
 				this.loadVideoById(youtubeId, 0);
 			},
-			updateTitle(title) {
-				this.song.title = title;
-			},
-			updateThumbnail(thumbnail) {
-				this.song.thumbnail = thumbnail;
-			},
 			setPlaybackRate(rate) {
 				if (rate) {
 					this.video.playbackRate = rate;

Some files were not shown because too many files changed in this diff