feat: hide validation error when field has been edited

This commit is contained in:
tdgao
2026-09-03 16:20:00 -06:00
parent aa33e15cf5
commit 47f550b18d
12 changed files with 250 additions and 79 deletions
@@ -28,8 +28,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { LightBulbIcon, TriangleAlertIcon, XCircleIcon } from '@modrinth/assets' import { LightBulbIcon, TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
import type { FieldValidationMessage } from '@modrinth/moderation' import type { FieldValidationMessage } from '@modrinth/moderation'
import { useVIntl } from '@modrinth/ui' import { injectProjectPageContext, useVIntl } from '@modrinth/ui'
import { computed, onScopeDispose, shallowRef, watch } from 'vue' import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue'
type ValidationCheck = Omit<FieldValidationMessage, 'code'> & { code?: string } type ValidationCheck = Omit<FieldValidationMessage, 'code'> & { code?: string }
@@ -39,6 +39,8 @@ const props = withDefaults(
defineProps<{ defineProps<{
check?: ValidationCheckInput check?: ValidationCheckInput
debounce?: number debounce?: number
projectField?: unknown
currentField?: unknown
}>(), }>(),
{ {
check: null, check: null,
@@ -47,31 +49,65 @@ const props = withDefaults(
) )
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { projectValidationLoading } = injectProjectPageContext()
const displayedCheck = shallowRef<ValidationCheckInput>(props.check) const displayedCheck = shallowRef<ValidationCheckInput>(props.check)
const validationIsStale = ref(props.projectField !== props.currentField)
let debounceTimer: ReturnType<typeof setTimeout> | undefined let debounceTimer: ReturnType<typeof setTimeout> | undefined
let validationRefreshCompleted = false
function updateDisplayedCheck(check: ValidationCheckInput) {
clearTimeout(debounceTimer)
const update = () => {
displayedCheck.value = check
if (
validationRefreshCompleted &&
!projectValidationLoading.value &&
props.projectField === props.currentField
) {
validationIsStale.value = false
validationRefreshCompleted = false
}
}
if (props.debounce <= 0) {
update()
return
}
debounceTimer = setTimeout(update, props.debounce)
}
watch( watch(
() => props.check, () => [props.projectField, props.currentField] as const,
(check) => { ([projectField, currentField], [previousProjectField]) => {
clearTimeout(debounceTimer) if (projectField !== currentField) {
if (props.debounce <= 0) { validationIsStale.value = true
displayedCheck.value = check validationRefreshCompleted = false
return } else if (projectField === previousProjectField) {
validationIsStale.value = false
validationRefreshCompleted = false
} }
debounceTimer = setTimeout(() => {
displayedCheck.value = check
}, props.debounce)
}, },
) )
watch(projectValidationLoading, (loading, wasLoading) => {
if (!loading && wasLoading) {
validationRefreshCompleted = true
updateDisplayedCheck(props.check)
}
})
watch(() => props.check, updateDisplayedCheck)
onScopeDispose(() => clearTimeout(debounceTimer)) onScopeDispose(() => clearTimeout(debounceTimer))
const validations = computed(() => const validations = computed(() => {
Array.isArray(displayedCheck.value) if (validationIsStale.value || projectValidationLoading.value) return []
return Array.isArray(displayedCheck.value)
? displayedCheck.value ? displayedCheck.value
: displayedCheck.value : displayedCheck.value
? [displayedCheck.value] ? [displayedCheck.value]
: [], : []
) })
</script> </script>
+39 -35
View File
@@ -842,6 +842,14 @@ const messages = defineMessages({
id: 'project.notification.updated.message', id: 'project.notification.updated.message',
defaultMessage: 'Your project has been updated.', defaultMessage: 'Your project has been updated.',
}, },
projectReviewSaveFailed: {
id: 'project.notification.review-save-failed.title',
defaultMessage: 'Failed to save project in review',
},
projectReviewSaveFailedDescription: {
id: 'project.notification.review-save-failed.description',
defaultMessage: 'You cannot save edits to your project which result in failing validation.',
},
reviewEnvironmentSettings: { reviewEnvironmentSettings: {
id: 'project.environment.migration.review-button', id: 'project.environment.migration.review-button',
defaultMessage: 'Review environment settings', defaultMessage: 'Review environment settings',
@@ -1377,6 +1385,30 @@ function mergeV3ProjectPatch(old, data) {
return merged return merged
} }
const PROJECT_REVIEW_VALIDATION_ERROR =
'project must have no required validation nags before or while under review'
function addProjectMutationErrorNotification(error) {
const description =
error?.v1Error?.description ??
error?.responseData?.description ??
error?.data?.description ??
error?.message
const isProjectReviewValidationError = description === PROJECT_REVIEW_VALIDATION_ERROR
addNotification({
title: formatMessage(
isProjectReviewValidationError
? messages.projectReviewSaveFailed
: commonMessages.errorNotificationTitle,
),
text: isProjectReviewValidationError
? formatMessage(messages.projectReviewSaveFailedDescription)
: description,
type: 'error',
})
}
// Mutation for patching project data // Mutation for patching project data
const patchProjectMutation = useMutation({ const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => { mutationFn: async ({ projectId, data }) => {
@@ -1412,11 +1444,7 @@ const patchProjectMutation = useMutation({
if (context?.previousV3) { if (context?.previousV3) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3) queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3)
} }
addNotification({ addProjectMutationErrorNotification(err)
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
}, },
onSettled: async () => { onSettled: async () => {
@@ -1447,11 +1475,7 @@ const patchStatusMutation = useMutation({
if (context?.previousProject) { if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject) queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
} }
addNotification({ addProjectMutationErrorNotification(err)
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
}, },
onSettled: async () => { onSettled: async () => {
@@ -1491,11 +1515,7 @@ const patchProjectV3Mutation = useMutation({
if (context?.previousV2) { if (context?.previousV2) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2) queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2)
} }
addNotification({ addProjectMutationErrorNotification(err)
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
}, },
onSettled: () => { onSettled: () => {
@@ -1519,11 +1539,7 @@ const patchIconMutation = useMutation({
}, },
onError: (err) => { onError: (err) => {
addNotification({ addProjectMutationErrorNotification(err)
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
}, },
onSettled: async () => { onSettled: async () => {
@@ -1572,11 +1588,7 @@ const createGalleryItemMutation = useMutation({
if (context?.previousProject) { if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject) queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
} }
addNotification({ addProjectMutationErrorNotification(err)
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
}, },
onSettled: async () => { onSettled: async () => {
@@ -1625,11 +1637,7 @@ const editGalleryItemMutation = useMutation({
if (context?.previousProject) { if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject) queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
} }
addNotification({ addProjectMutationErrorNotification(err)
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
}, },
onSettled: async () => { onSettled: async () => {
@@ -1662,11 +1670,7 @@ const deleteGalleryItemMutation = useMutation({
if (context?.previousProject) { if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject) queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
} }
addNotification({ addProjectMutationErrorNotification(err)
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
}, },
onSettled: async () => { onSettled: async () => {
@@ -44,7 +44,11 @@
:maxlength="64" :maxlength="64"
placeholder="Enter title..." placeholder="Enter title..."
/> />
<ValidationMessage :check="galleryTitleValidation" /> <ValidationMessage
:check="galleryTitleValidation"
:project-field="filteredGallery[editIndex]?.title ?? ''"
:current-field="editTitle"
/>
<label for="gallery-image-desc"> <label for="gallery-image-desc">
<span class="label__title">Description</span> <span class="label__title">Description</span>
</label> </label>
@@ -54,7 +58,11 @@
:maxlength="255" :maxlength="255"
placeholder="Enter description..." placeholder="Enter description..."
/> />
<ValidationMessage :check="galleryDescriptionValidation" /> <ValidationMessage
:check="galleryDescriptionValidation"
:project-field="filteredGallery[editIndex]?.description ?? ''"
:current-field="editDescription"
/>
<label for="gallery-image-ordering"> <label for="gallery-image-ordering">
<span class="label__title">Order Index</span> <span class="label__title">Order Index</span>
</label> </label>
@@ -20,7 +20,12 @@
:disabled="!hasPermission" :disabled="!hasPermission"
:on-image-upload="onUploadHandler" :on-image-upload="onUploadHandler"
/> />
<ValidationMessage :check="descriptionValidation" class="mt-2" /> <ValidationMessage
:check="descriptionValidation"
:project-field="saved.description"
:current-field="current.description"
class="mt-2"
/>
</div> </div>
<UnsavedChangesPopup <UnsavedChangesPopup
:original="saved" :original="saved"
@@ -294,6 +294,8 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
</p> </p>
<ValidationMessage <ValidationMessage
:check="[...disclosureValidation, ...disclosureTextValidation]" :check="[...disclosureValidation, ...disclosureTextValidation]"
:project-field="JSON.stringify(savedSnapshot)"
:current-field="JSON.stringify(currentSnapshot)"
class="mb-4" class="mb-4"
/> />
<EmptyState <EmptyState
@@ -45,7 +45,12 @@
:maxlength="64" :maxlength="64"
placeholder="Enter title..." placeholder="Enter title..."
/> />
<ValidationMessage class="mt-2" :check="galleryTitleValidation" /> <ValidationMessage
class="mt-2 max-w-[550px]"
:check="galleryTitleValidation"
:project-field="filteredGallery[editIndex]?.title ?? ''"
:current-field="editTitle"
/>
<label for="gallery-image-desc"> <label for="gallery-image-desc">
<span class="label__title">Description</span> <span class="label__title">Description</span>
</label> </label>
@@ -55,7 +60,12 @@
:maxlength="255" :maxlength="255"
placeholder="Enter description..." placeholder="Enter description..."
/> />
<ValidationMessage class="mt-2" :check="galleryDescriptionValidation" /> <ValidationMessage
class="mt-2 max-w-[550px]"
:check="galleryDescriptionValidation"
:project-field="filteredGallery[editIndex]?.description ?? ''"
:current-field="editDescription"
/>
<label for="gallery-image-ordering"> <label for="gallery-image-ordering">
<span class="label__title">Order Index</span> <span class="label__title">Order Index</span>
</label> </label>
@@ -179,7 +179,12 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
<div class="base-card block"> <div class="base-card block">
<div class="group relative float-end ml-4"> <div class="group relative float-end ml-4">
<IconSelect v-model="current.icon" /> <IconSelect v-model="current.icon" />
<ValidationMessage :check="iconValidation" class="mt-2" /> <ValidationMessage
:check="iconValidation"
:project-field="saved.icon"
:current-field="current.icon"
class="mt-2"
/>
</div> </div>
<div> <div>
<SettingsLabel <SettingsLabel
@@ -197,7 +202,12 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
wrapper-class="flex-grow" wrapper-class="flex-grow"
/> />
</div> </div>
<ValidationMessage :check="titleValidation" class="mt-2" /> <ValidationMessage
:check="titleValidation"
:project-field="saved.title"
:current-field="current.title"
class="mt-2"
/>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<SettingsLabel <SettingsLabel
@@ -213,7 +223,12 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
:maxlength="120" :maxlength="120"
wrapper-class="w-full" wrapper-class="w-full"
/> />
<ValidationMessage :check="taglineValidation" class="mt-2" /> <ValidationMessage
:check="taglineValidation"
:project-field="saved.tagline"
:current-field="current.tagline"
class="mt-2"
/>
</div> </div>
<div class="mt-4" @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut"> <div class="mt-4" @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut">
<SettingsLabel id="project-url" :title="messages.urlTitle" /> <SettingsLabel id="project-url" :title="messages.urlTitle" />
@@ -29,7 +29,12 @@
wrapper-class="w-full max-w-72" wrapper-class="w-full max-w-72"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="nameValidation" class="mt-2" /> <ValidationMessage
:check="nameValidation"
:project-field="project.name"
:current-field="name"
class="mt-2"
/>
</div> </div>
<div @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut"> <div @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut">
@@ -69,7 +74,12 @@
:disabled="!hasPermission" :disabled="!hasPermission"
resize="vertical" resize="vertical"
/> />
<ValidationMessage :check="summaryValidation" class="mt-2" /> <ValidationMessage
:check="summaryValidation"
:project-field="project.summary"
:current-field="summary"
class="mt-2"
/>
</div> </div>
<div> <div>
@@ -109,7 +119,12 @@
</Button> </Button>
</div> </div>
</div> </div>
<ValidationMessage :check="iconValidation" class="mt-2" /> <ValidationMessage
:check="iconValidation"
:project-field="false"
:current-field="Boolean(icon || deletedIcon)"
class="mt-2"
/>
</div> </div>
<!-- Server Project Settings --> <!-- Server Project Settings -->
@@ -36,7 +36,12 @@
:disabled="!hasPermission" :disabled="!hasPermission"
trigger-type="base" trigger-type="base"
/> />
<ValidationMessage :check="licenseSelectionValidation" class="mt-2" /> <ValidationMessage
:check="licenseSelectionValidation"
:project-field="project.license.id"
:current-field="licenseId"
class="mt-2"
/>
</div> </div>
</div> </div>
@@ -85,8 +90,16 @@
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'" :disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
wrapper-class="w-full" wrapper-class="w-full"
/> />
<ValidationMessage :check="customLicenseValidation" /> <ValidationMessage
<ValidationMessage :check="effectiveLicenseCheck" /> :check="customLicenseValidation"
:project-field="project.license.id"
:current-field="licenseId"
/>
<ValidationMessage
:check="effectiveLicenseCheck"
:project-field="project.license.url ?? ''"
:current-field="current.licenseUrl"
/>
</div> </div>
</div> </div>
@@ -4,7 +4,12 @@
<!-- Server Project Links --> <!-- Server Project Links -->
<section v-if="isServerProject" class="universal-card"> <section v-if="isServerProject" class="universal-card">
<h2>External links</h2> <h2>External links</h2>
<ValidationMessage :check="externalLinksValidation" class="mb-4" /> <ValidationMessage
:check="externalLinksValidation"
:project-field="JSON.stringify(saved)"
:current-field="JSON.stringify(current)"
class="mb-4"
/>
<div class="adjacent-input"> <div class="adjacent-input">
<label id="server-website" title="Your server's website."> <label id="server-website" title="Your server's website.">
<span class="label__title">Website</span> <span class="label__title">Website</span>
@@ -18,7 +23,11 @@
maxlength="2048" maxlength="2048"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="siteCheck" /> <ValidationMessage
:check="siteCheck"
:project-field="saved.site"
:current-field="current.site"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label id="server-store" title="Your server's store page."> <label id="server-store" title="Your server's store page.">
@@ -33,7 +42,11 @@
maxlength="2048" maxlength="2048"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="storeCheck" /> <ValidationMessage
:check="storeCheck"
:project-field="saved.store"
:current-field="current.store"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label <label
@@ -53,7 +66,11 @@
maxlength="2048" maxlength="2048"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="wikiCheck" /> <ValidationMessage
:check="wikiCheck"
:project-field="saved.wiki"
:current-field="current.wiki"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label id="server-discord" title="An invitation link to your Discord server."> <label id="server-discord" title="An invitation link to your Discord server.">
@@ -68,14 +85,23 @@
maxlength="2048" maxlength="2048"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="discordInviteCheck" /> <ValidationMessage
:check="discordInviteCheck"
:project-field="saved.discord"
:current-field="current.discord"
/>
</div> </div>
</section> </section>
<!-- Standard Project Links --> <!-- Standard Project Links -->
<section v-if="!isServerProject" class="universal-card"> <section v-if="!isServerProject" class="universal-card">
<h2>External links</h2> <h2>External links</h2>
<ValidationMessage :check="externalLinksValidation" class="mb-4" /> <ValidationMessage
:check="externalLinksValidation"
:project-field="JSON.stringify(saved)"
:current-field="JSON.stringify(current)"
class="mb-4"
/>
<div class="adjacent-input"> <div class="adjacent-input">
<label <label
id="project-issue-tracker" id="project-issue-tracker"
@@ -94,7 +120,11 @@
:maxlength="2048" :maxlength="2048"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="issuesCheck" /> <ValidationMessage
:check="issuesCheck"
:project-field="saved.issues"
:current-field="current.issues"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label <label
@@ -114,7 +144,11 @@
placeholder="Enter a valid URL" placeholder="Enter a valid URL"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="sourceCheck" /> <ValidationMessage
:check="sourceCheck"
:project-field="saved.source"
:current-field="current.source"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label <label
@@ -134,7 +168,11 @@
placeholder="Enter a valid URL" placeholder="Enter a valid URL"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="wikiCheck" /> <ValidationMessage
:check="wikiCheck"
:project-field="saved.wiki"
:current-field="current.wiki"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server."> <label id="project-discord-invite" title="An invitation link to your Discord server.">
@@ -149,7 +187,11 @@
placeholder="Enter a valid URL" placeholder="Enter a valid URL"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="discordInviteCheck" /> <ValidationMessage
:check="discordInviteCheck"
:project-field="saved.discord"
:current-field="current.discord"
/>
</div> </div>
<span class="label"> <span class="label">
<span class="label__title">Donation links</span> <span class="label__title">Donation links</span>
@@ -20,7 +20,12 @@
:placeholder="formatMessage(messages.selectRegionPlaceholder)" :placeholder="formatMessage(messages.selectRegionPlaceholder)"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="regionValidation" class="mt-2" /> <ValidationMessage
:check="regionValidation"
:project-field="projectV3?.minecraft_server?.region ?? ''"
:current-field="region"
class="mt-2"
/>
</div> </div>
<!-- Language --> <!-- Language -->
@@ -43,7 +48,14 @@
:placeholder="formatMessage(messages.selectLanguagesPlaceholder)" :placeholder="formatMessage(messages.selectLanguagesPlaceholder)"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<ValidationMessage :check="languageValidation" class="mt-2" /> <ValidationMessage
:check="languageValidation"
:project-field="
JSON.stringify([...(projectV3?.minecraft_server?.languages ?? [])].sort())
"
:current-field="JSON.stringify([...languages].sort())"
class="mt-2"
/>
</div> </div>
<!-- Java Address --> <!-- Java Address -->
@@ -129,7 +141,12 @@
/></template> /></template>
</IntlFormatted> </IntlFormatted>
</div> </div>
<ValidationMessage :check="javaAddressValidation" class="mt-2" /> <ValidationMessage
:check="javaAddressValidation"
:project-field="projectV3?.minecraft_java_server?.address ?? ''"
:current-field="javaAddress.trim()"
class="mt-2"
/>
</div> </div>
<!-- Bedrock Address --> <!-- Bedrock Address -->
@@ -409,7 +409,11 @@ const toggleFeatured = (tag: string) => {
</Checkbox> </Checkbox>
</div> </div>
</div> </div>
<ValidationMessage :check="tagValidation" /> <ValidationMessage
:check="tagValidation"
:project-field="JSON.stringify(saved)"
:current-field="JSON.stringify(current)"
/>
</div> </div>
<UnsavedChangesPopup <UnsavedChangesPopup
:original="saved" :original="saved"