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">
import { LightBulbIcon, TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
import type { FieldValidationMessage } from '@modrinth/moderation'
import { useVIntl } from '@modrinth/ui'
import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { injectProjectPageContext, useVIntl } from '@modrinth/ui'
import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue'
type ValidationCheck = Omit<FieldValidationMessage, 'code'> & { code?: string }
@@ -39,6 +39,8 @@ const props = withDefaults(
defineProps<{
check?: ValidationCheckInput
debounce?: number
projectField?: unknown
currentField?: unknown
}>(),
{
check: null,
@@ -47,31 +49,65 @@ const props = withDefaults(
)
const { formatMessage } = useVIntl()
const { projectValidationLoading } = injectProjectPageContext()
const displayedCheck = shallowRef<ValidationCheckInput>(props.check)
const validationIsStale = ref(props.projectField !== props.currentField)
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(
() => props.check,
(check) => {
clearTimeout(debounceTimer)
if (props.debounce <= 0) {
displayedCheck.value = check
return
() => [props.projectField, props.currentField] as const,
([projectField, currentField], [previousProjectField]) => {
if (projectField !== currentField) {
validationIsStale.value = true
validationRefreshCompleted = false
} 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))
const validations = computed(() =>
Array.isArray(displayedCheck.value)
const validations = computed(() => {
if (validationIsStale.value || projectValidationLoading.value) return []
return Array.isArray(displayedCheck.value)
? displayedCheck.value
: displayedCheck.value
? [displayedCheck.value]
: [],
)
: []
})
</script>
+39 -35
View File
@@ -842,6 +842,14 @@ const messages = defineMessages({
id: 'project.notification.updated.message',
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: {
id: 'project.environment.migration.review-button',
defaultMessage: 'Review environment settings',
@@ -1377,6 +1385,30 @@ function mergeV3ProjectPatch(old, data) {
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
const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
@@ -1412,11 +1444,7 @@ const patchProjectMutation = useMutation({
if (context?.previousV3) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1447,11 +1475,7 @@ const patchStatusMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1491,11 +1515,7 @@ const patchProjectV3Mutation = useMutation({
if (context?.previousV2) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
addProjectMutationErrorNotification(err)
},
onSettled: () => {
@@ -1519,11 +1539,7 @@ const patchIconMutation = useMutation({
},
onError: (err) => {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1572,11 +1588,7 @@ const createGalleryItemMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1625,11 +1637,7 @@ const editGalleryItemMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1662,11 +1670,7 @@ const deleteGalleryItemMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -44,7 +44,11 @@
:maxlength="64"
placeholder="Enter title..."
/>
<ValidationMessage :check="galleryTitleValidation" />
<ValidationMessage
:check="galleryTitleValidation"
:project-field="filteredGallery[editIndex]?.title ?? ''"
:current-field="editTitle"
/>
<label for="gallery-image-desc">
<span class="label__title">Description</span>
</label>
@@ -54,7 +58,11 @@
:maxlength="255"
placeholder="Enter description..."
/>
<ValidationMessage :check="galleryDescriptionValidation" />
<ValidationMessage
:check="galleryDescriptionValidation"
:project-field="filteredGallery[editIndex]?.description ?? ''"
:current-field="editDescription"
/>
<label for="gallery-image-ordering">
<span class="label__title">Order Index</span>
</label>
@@ -20,7 +20,12 @@
:disabled="!hasPermission"
: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>
<UnsavedChangesPopup
:original="saved"
@@ -294,6 +294,8 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
</p>
<ValidationMessage
:check="[...disclosureValidation, ...disclosureTextValidation]"
:project-field="JSON.stringify(savedSnapshot)"
:current-field="JSON.stringify(currentSnapshot)"
class="mb-4"
/>
<EmptyState
@@ -45,7 +45,12 @@
:maxlength="64"
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">
<span class="label__title">Description</span>
</label>
@@ -55,7 +60,12 @@
:maxlength="255"
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">
<span class="label__title">Order Index</span>
</label>
@@ -179,7 +179,12 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
<div class="base-card block">
<div class="group relative float-end ml-4">
<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>
<SettingsLabel
@@ -197,7 +202,12 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
wrapper-class="flex-grow"
/>
</div>
<ValidationMessage :check="titleValidation" class="mt-2" />
<ValidationMessage
:check="titleValidation"
:project-field="saved.title"
:current-field="current.title"
class="mt-2"
/>
</div>
<div class="mt-4">
<SettingsLabel
@@ -213,7 +223,12 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
:maxlength="120"
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 class="mt-4" @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut">
<SettingsLabel id="project-url" :title="messages.urlTitle" />
@@ -29,7 +29,12 @@
wrapper-class="w-full max-w-72"
:disabled="!hasPermission"
/>
<ValidationMessage :check="nameValidation" class="mt-2" />
<ValidationMessage
:check="nameValidation"
:project-field="project.name"
:current-field="name"
class="mt-2"
/>
</div>
<div @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut">
@@ -69,7 +74,12 @@
:disabled="!hasPermission"
resize="vertical"
/>
<ValidationMessage :check="summaryValidation" class="mt-2" />
<ValidationMessage
:check="summaryValidation"
:project-field="project.summary"
:current-field="summary"
class="mt-2"
/>
</div>
<div>
@@ -109,7 +119,12 @@
</Button>
</div>
</div>
<ValidationMessage :check="iconValidation" class="mt-2" />
<ValidationMessage
:check="iconValidation"
:project-field="false"
:current-field="Boolean(icon || deletedIcon)"
class="mt-2"
/>
</div>
<!-- Server Project Settings -->
@@ -36,7 +36,12 @@
:disabled="!hasPermission"
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>
@@ -85,8 +90,16 @@
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
wrapper-class="w-full"
/>
<ValidationMessage :check="customLicenseValidation" />
<ValidationMessage :check="effectiveLicenseCheck" />
<ValidationMessage
:check="customLicenseValidation"
:project-field="project.license.id"
:current-field="licenseId"
/>
<ValidationMessage
:check="effectiveLicenseCheck"
:project-field="project.license.url ?? ''"
:current-field="current.licenseUrl"
/>
</div>
</div>
@@ -4,7 +4,12 @@
<!-- Server Project Links -->
<section v-if="isServerProject" class="universal-card">
<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">
<label id="server-website" title="Your server's website.">
<span class="label__title">Website</span>
@@ -18,7 +23,11 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<ValidationMessage :check="siteCheck" />
<ValidationMessage
:check="siteCheck"
:project-field="saved.site"
:current-field="current.site"
/>
</div>
<div class="adjacent-input">
<label id="server-store" title="Your server's store page.">
@@ -33,7 +42,11 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<ValidationMessage :check="storeCheck" />
<ValidationMessage
:check="storeCheck"
:project-field="saved.store"
:current-field="current.store"
/>
</div>
<div class="adjacent-input">
<label
@@ -53,7 +66,11 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<ValidationMessage :check="wikiCheck" />
<ValidationMessage
:check="wikiCheck"
:project-field="saved.wiki"
:current-field="current.wiki"
/>
</div>
<div class="adjacent-input">
<label id="server-discord" title="An invitation link to your Discord server.">
@@ -68,14 +85,23 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<ValidationMessage :check="discordInviteCheck" />
<ValidationMessage
:check="discordInviteCheck"
:project-field="saved.discord"
:current-field="current.discord"
/>
</div>
</section>
<!-- Standard Project Links -->
<section v-if="!isServerProject" class="universal-card">
<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">
<label
id="project-issue-tracker"
@@ -94,7 +120,11 @@
:maxlength="2048"
:disabled="!hasPermission"
/>
<ValidationMessage :check="issuesCheck" />
<ValidationMessage
:check="issuesCheck"
:project-field="saved.issues"
:current-field="current.issues"
/>
</div>
<div class="adjacent-input">
<label
@@ -114,7 +144,11 @@
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
<ValidationMessage :check="sourceCheck" />
<ValidationMessage
:check="sourceCheck"
:project-field="saved.source"
:current-field="current.source"
/>
</div>
<div class="adjacent-input">
<label
@@ -134,7 +168,11 @@
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
<ValidationMessage :check="wikiCheck" />
<ValidationMessage
:check="wikiCheck"
:project-field="saved.wiki"
:current-field="current.wiki"
/>
</div>
<div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server.">
@@ -149,7 +187,11 @@
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
<ValidationMessage :check="discordInviteCheck" />
<ValidationMessage
:check="discordInviteCheck"
:project-field="saved.discord"
:current-field="current.discord"
/>
</div>
<span class="label">
<span class="label__title">Donation links</span>
@@ -20,7 +20,12 @@
:placeholder="formatMessage(messages.selectRegionPlaceholder)"
:disabled="!hasPermission"
/>
<ValidationMessage :check="regionValidation" class="mt-2" />
<ValidationMessage
:check="regionValidation"
:project-field="projectV3?.minecraft_server?.region ?? ''"
:current-field="region"
class="mt-2"
/>
</div>
<!-- Language -->
@@ -43,7 +48,14 @@
:placeholder="formatMessage(messages.selectLanguagesPlaceholder)"
: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>
<!-- Java Address -->
@@ -129,7 +141,12 @@
/></template>
</IntlFormatted>
</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>
<!-- Bedrock Address -->
@@ -409,7 +409,11 @@ const toggleFeatured = (tag: string) => {
</Checkbox>
</div>
</div>
<ValidationMessage :check="tagValidation" />
<ValidationMessage
:check="tagValidation"
:project-field="JSON.stringify(saved)"
:current-field="JSON.stringify(current)"
/>
</div>
<UnsavedChangesPopup
:original="saved"