feat: update nags with field validation, splitting between required/warning nags

This commit is contained in:
tdgao
2026-08-26 11:21:48 -06:00
parent 0d61960855
commit a348240686
22 changed files with 1189 additions and 598 deletions
@@ -1,11 +1,17 @@
<template>
<div
v-if="check && check.severity !== 'valid'"
class="flex w-full items-center gap-1.5"
:class="check.severity === 'error' ? 'text-red' : 'text-orange'"
>
<component :is="icon" class="my-auto" />
{{ message }}
<div v-if="validations.length > 0" class="flex w-full flex-col gap-1.5">
<div
v-for="(validation, index) in validations"
:key="validation.message?.id ?? index"
class="flex w-full items-center gap-1.5"
:class="validation.severity === 'error' ? 'text-red' : 'text-orange'"
>
<component
:is="validation.severity === 'error' ? XCircleIcon : TriangleAlertIcon"
class="my-auto"
/>
{{ validation.message ? formatMessage(validation.message, validation.values) : undefined }}
</div>
</div>
</template>
@@ -20,16 +26,15 @@ interface ValidationCheck {
values?: Record<string, unknown>
}
const props = withDefaults(defineProps<{ check?: ValidationCheck | null }>(), {
const props = withDefaults(defineProps<{ check?: ValidationCheck | ValidationCheck[] | null }>(), {
check: null,
})
const { formatMessage } = useVIntl()
const icon = computed(() => (props.check?.severity === 'error' ? XCircleIcon : TriangleAlertIcon))
const message = computed(() => {
if (!props.check?.message) return undefined
return formatMessage(props.check.message, props.check.values)
})
const validations = computed(() =>
(Array.isArray(props.check) ? props.check : props.check ? [props.check] : []).filter(
(validation) => validation.severity !== 'valid',
),
)
</script>
@@ -339,7 +339,11 @@ const summaryValidation = useProjectSummaryValidation(description, name)
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (nameValidation.value || summaryValidation.value) return true
if (
nameValidation.value.some((validation) => validation.severity === 'error') ||
summaryValidation.value.some((validation) => validation.severity === 'error')
)
return true
if (!name.value.trim() || !slug.value.trim()) return true
if (!manualSlug.value && checkingSlugSuggestions.value) return true
if (!manualSlug.value && !slugSuggestions.value.includes(slug.value)) return true
@@ -32,51 +32,72 @@
</IconButton>
</div>
</div>
<div v-if="!collapsed" class="mt-4 grid grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] gap-2">
<div v-if="!collapsed" class="relative mt-4">
<div
v-for="nag in visibleNags"
:key="nag.id"
class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
class="nag-scroll-shadow-left pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-8 bg-surface-3 transition-opacity duration-200"
:class="showLeftNagShadow ? 'opacity-100' : 'opacity-0'"
/>
<div
ref="nagScroller"
class="flex w-full cursor-grab select-none gap-2 overflow-x-auto overflow-y-hidden pb-2"
:class="{ 'is-dragging': draggingNags }"
@pointerdown="onNagPointerDown"
@pointermove="onNagPointerMove"
@pointerup="finishNagDrag"
@pointercancel="finishNagDrag"
@click.capture="onNagClick"
@wheel="onNagWheel"
@scroll="updateNagScrollShadows"
>
<span class="flex items-center gap-2 font-medium text-contrast">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'size-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link mt-auto"
<div
v-for="nag in visibleNags"
:key="nag.id"
class="flex w-72 shrink-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
{{ getFormattedMessage(nag.link.title) }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<Button
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
type="colored"
color="orange"
:disabled="!canSubmitForReview"
@click="submitForReview"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReviewButton) }}
</Button>
<span class="flex items-center gap-2 font-medium text-contrast">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'size-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link mt-auto"
>
{{ getFormattedMessage(nag.link.title) }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<Button
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
type="colored"
color="orange"
:disabled="!canSubmitForReview"
@click="submitForReview"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReviewButton) }}
</Button>
</div>
</div>
<div
class="nag-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-3 transition-opacity duration-200"
:class="showRightNagShadow ? 'opacity-100' : 'opacity-0'"
/>
</div>
</div>
</template>
@@ -92,15 +113,17 @@ import {
SendIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { nags } from '@modrinth/moderation'
import type { Nag, NagContext, NagStatus, ProjectTitleMetadata } from '@modrinth/moderation'
import { nags, validateProjectFields } from '@modrinth/moderation'
import { Button, IconButton } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Tags {
rejectedStatuses: string[]
gameVersions: { version: string }[]
loaders: { name: string }[]
}
interface Props {
@@ -176,9 +199,125 @@ const emit = defineEmits<{
setProcessing: [processing: boolean]
}>()
const nagScroller = ref<HTMLElement | null>(null)
const showLeftNagShadow = ref(false)
const showRightNagShadow = ref(false)
const draggingNags = ref(false)
let nagScrollerResizeObserver: ResizeObserver | null = null
let nagDragPointerId: number | null = null
let nagDragCaptureTarget: Element | null = null
let nagDragStartX = 0
let nagDragStartScrollLeft = 0
let suppressNagClick = false
let suppressNagClickTimeout: ReturnType<typeof setTimeout> | null = null
function updateNagScrollShadows() {
const el = nagScroller.value
if (!el) {
showLeftNagShadow.value = false
showRightNagShadow.value = false
return
}
showLeftNagShadow.value = el.scrollLeft > 0
showRightNagShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
}
function onNagWheel(event: WheelEvent) {
const el = nagScroller.value
if (!el || el.scrollWidth <= el.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
event.preventDefault()
el.scrollLeft += delta
}
function onNagPointerDown(event: PointerEvent) {
const el = nagScroller.value
if (!el || event.pointerType === 'touch' || event.button !== 0) return
nagDragPointerId = event.pointerId
nagDragStartX = event.clientX
nagDragStartScrollLeft = el.scrollLeft
suppressNagClick = false
nagDragCaptureTarget =
event.target instanceof Element ? (event.target.closest('a, button') ?? el) : el
nagDragCaptureTarget.setPointerCapture(event.pointerId)
}
function onNagPointerMove(event: PointerEvent) {
const el = nagScroller.value
if (!el || event.pointerId !== nagDragPointerId) return
const distance = event.clientX - nagDragStartX
if (!draggingNags.value && Math.abs(distance) < 4) return
draggingNags.value = true
suppressNagClick = true
event.preventDefault()
el.scrollLeft = nagDragStartScrollLeft - distance
}
function finishNagDrag(event: PointerEvent) {
if (event.pointerId !== nagDragPointerId) return
if (nagDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
nagDragCaptureTarget.releasePointerCapture(event.pointerId)
}
nagDragPointerId = null
nagDragCaptureTarget = null
draggingNags.value = false
if (suppressNagClick) {
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
suppressNagClickTimeout = setTimeout(() => {
suppressNagClick = false
suppressNagClickTimeout = null
}, 0)
}
}
function onNagClick(event: MouseEvent) {
if (!suppressNagClick) return
event.preventDefault()
event.stopPropagation()
suppressNagClick = false
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
suppressNagClickTimeout = null
}
onMounted(() => {
nagScrollerResizeObserver = new ResizeObserver(updateNagScrollShadows)
if (nagScroller.value) nagScrollerResizeObserver.observe(nagScroller.value)
nextTick(updateNagScrollShadows)
})
onBeforeUnmount(() => {
nagScrollerResizeObserver?.disconnect()
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
})
watch(nagScroller, (el, previousEl) => {
if (previousEl) nagScrollerResizeObserver?.unobserve(previousEl)
if (el) nagScrollerResizeObserver?.observe(el)
nextTick(updateNagScrollShadows)
})
const titleMetadata = computed<ProjectTitleMetadata>(() => ({
gameVersions: props.tags.gameVersions.map(({ version }) => version),
loaders: props.tags.loaders.map(({ name }) => name),
}))
const projectValidation = computed(() =>
validateProjectFields(props.projectV3, titleMetadata.value),
)
const nagContext = computed<NagContext>(() => ({
project: props.project,
projectV3: props.projectV3,
projectValidation: projectValidation.value,
versions: props.versions,
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName,
@@ -247,6 +386,8 @@ const visibleNags = computed<Nag[]>(() => {
return finalNags
})
watch(visibleNags, () => nextTick(updateNagScrollShadows))
function shouldShowLink(nag: Nag): boolean {
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false
}
@@ -298,4 +439,19 @@ function getFormattedMessage(message: string | MessageDescriptor): string {
.duration-250 {
transition-duration: 250ms;
}
.is-dragging,
.is-dragging * {
cursor: grabbing !important;
}
.nag-scroll-shadow-left {
-webkit-mask-image: linear-gradient(to right, black, transparent);
mask-image: linear-gradient(to right, black, transparent);
}
.nag-scroll-shadow-right {
-webkit-mask-image: linear-gradient(to left, black, transparent);
mask-image: linear-gradient(to left, black, transparent);
}
</style>
@@ -98,9 +98,10 @@ export function useProjectDescriptionValidation(
requestId++
})
const validation = computed<ProjectTextValidationResult | LinkCheckResult | null>(
() => validateProjectDescription(toValue(description)) ?? linkValidation.value,
)
const validation = computed<Array<ProjectTextValidationResult | LinkCheckResult>>(() => [
...validateProjectDescription(toValue(description)),
...(linkValidation.value ? [linkValidation.value] : []),
])
return {
pending,
+3 -1
View File
@@ -139,7 +139,9 @@
v-if="
projectV3 &&
currentMember &&
(projectV3.status === 'draft' || tags.rejectedStatuses.includes(projectV3.status))
(projectV3.status === 'draft' ||
projectV3.status === 'processing' ||
tags.rejectedStatuses.includes(projectV3.status))
"
:project="project"
:project-v3="projectV3"
@@ -386,7 +386,9 @@ const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryFieldsInvalid = computed(
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
)
// Constant for accepted file types
@@ -166,7 +166,7 @@ const moderatorSeeUserUi = computed<boolean>({
<ModerationProjectNags
v-if="
projectV3 &&
((currentMember && project.status === 'draft') ||
((currentMember && (project.status === 'draft' || project.status === 'processing')) ||
tags.rejectedStatuses.includes(project.status))
"
:project="project"
@@ -21,11 +21,6 @@
:on-image-upload="onUploadHandler"
/>
<ValidationMessage :check="descriptionValidation" class="mt-2" />
<div v-if="descriptionWarning" class="mt-2">
<SettingsInlineWarning>
{{ descriptionWarning }}
</SettingsInlineWarning>
</div>
</div>
<UnsavedChangesPopup
:original="saved"
@@ -44,13 +39,11 @@
</template>
<script lang="ts" setup>
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import {
commonProjectSettingsMessages,
ConfirmLeaveModal,
injectProjectPageContext,
MarkdownEditor,
SettingsInlineWarning,
UnsavedChangesPopup,
usePageLeaveSafety,
useSavable,
@@ -96,7 +89,9 @@ const hasPermission = computed(
)
const { pending: descriptionLinksPending, validation: descriptionValidation } =
useProjectDescriptionValidation(() => current.value.description)
const hasValidationIssues = computed(() => descriptionValidation.value?.severity === 'error')
const hasValidationIssues = computed(() =>
descriptionValidation.value.some((validation) => validation.severity === 'error'),
)
const canSave = computed(
() => hasPermission.value && !hasValidationIssues.value && !descriptionLinksPending.value,
)
@@ -106,17 +101,6 @@ async function save() {
await saveForm()
}
const descriptionWarning = computed(() => {
const text = current.value.description?.trim() || ''
const charCount = countText(text)
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`
}
return null
})
async function onUploadHandler(file: File) {
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
@@ -346,7 +346,9 @@ const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryFieldsInvalid = computed(
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
)
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
@@ -64,11 +64,6 @@
resize="vertical"
/>
<ValidationMessage :check="summaryValidation" class="mt-2" />
<div v-if="summaryWarning" class="my-2">
<SettingsInlineWarning>
{{ summaryWarning }}
</SettingsInlineWarning>
</div>
</div>
<div>
@@ -295,7 +290,7 @@
:saving="saving"
:can-save="canSave"
:save-disabled-reason="
hasPermission && hasValidationIssues
hasPermission && hasBlockingValidationIssues
? projectTextValidationMessages.resolveIssuesToSave
: undefined
"
@@ -308,7 +303,6 @@
<script setup>
import { ImageIcon, ScaleIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import {
Avatar,
Button,
@@ -435,8 +429,13 @@ const hasPermission = computed(() => {
const nameValidation = useProjectTitleValidation(name)
const summaryValidation = useProjectSummaryValidation(summary, name)
const hasValidationIssues = computed(() => !!nameValidation.value || !!summaryValidation.value)
const canSave = computed(() => hasPermission.value && !hasValidationIssues.value)
const hasValidationIssues = computed(
() =>
nameValidation.value.some((validation) => validation.severity === 'error') ||
summaryValidation.value.some((validation) => validation.severity === 'error'),
)
const hasBlockingValidationIssues = computed(() => hasValidationIssues.value && !isStaff.value)
const canSave = computed(() => hasPermission.value && !hasBlockingValidationIssues.value)
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
@@ -445,17 +444,6 @@ const hasDeletePermission = computed(() => {
return ((currentMember.value?.permissions ?? 0) & DELETE_PROJECT) === DELETE_PROJECT
})
const summaryWarning = computed(() => {
const text = summary.value?.trim() || ''
const charCount = text.length
if (charCount < MIN_SUMMARY_CHARS) {
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`
}
return null
})
const visibilityOptions = computed(() =>
tags.value.approvedStatuses
.filter((status) => status !== 'archived')