refactor: centralize project validation and nags into validation rules

This commit is contained in:
tdgao
2026-08-27 22:03:04 -06:00
parent 1f3f37c86c
commit 18c1f31514
83 changed files with 2752 additions and 3428 deletions
@@ -2,12 +2,22 @@
<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"
:key="validation.code ?? validation.message?.id ?? index"
class="flex w-full items-center gap-1.5"
:class="validation.severity === 'error' ? 'text-red' : 'text-orange'"
:class="{
'text-red': validation.severity === 'error',
'text-orange': validation.severity === 'warn' || validation.severity === 'warning',
'text-purple': validation.severity === 'suggestion',
}"
>
<component
:is="validation.severity === 'error' ? XCircleIcon : TriangleAlertIcon"
:is="
validation.severity === 'error'
? XCircleIcon
: validation.severity === 'suggestion'
? LightBulbIcon
: TriangleAlertIcon
"
class="my-auto"
/>
{{ validation.message ? formatMessage(validation.message, validation.values) : undefined }}
@@ -16,12 +26,13 @@
</template>
<script setup lang="ts">
import { TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
import { LightBulbIcon, TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
import { type MessageDescriptor, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
interface ValidationCheck {
severity: 'valid' | 'warn' | 'error'
code?: string
severity: 'valid' | 'warn' | 'warning' | 'suggestion' | 'error'
message?: MessageDescriptor
values?: Record<string, unknown>
}
@@ -160,8 +160,8 @@ import {
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import {
useProjectSummaryValidation,
useProjectTitleValidation,
@@ -50,12 +50,7 @@ defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modalRef"
header="Moderate by IDs"
width="36rem"
max-width="calc(100vw - 2rem)"
>
<NewModal ref="modalRef" header="Moderate by IDs" width="36rem" max-width="calc(100vw - 2rem)">
<form class="flex flex-col gap-4" @submit.prevent="apply">
<div class="flex flex-col gap-2">
<label class="font-semibold text-contrast" for="moderation-project-ids">
@@ -124,13 +124,14 @@ import {
TriangleAlertIcon,
} from '@modrinth/assets'
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { nags, validateProjectFields } from '@modrinth/moderation'
import { getNags, nagDestinations, validateProject } from '@modrinth/moderation'
import { Accordion, Button, IconButton } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Tags {
categories?: Labrinth.Tags.v2.Category[]
rejectedStatuses: string[]
gameVersions: { version: string }[]
loaders: { name: string }[]
@@ -327,24 +328,17 @@ watch(nagScroller, (el, previousEl) => {
nextTick(updateNagScrollShadows)
})
const projectValidation = computed(() => validateProjectFields(props.projectV3))
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,
currentMember: props.currentMember?.user,
currentRoute: props.routeName,
tags: props.tags,
submitProject: submitForReview,
}))
const canSubmitForReview = computed(() => {
return (
applicableNags.value.filter((nag) => nag.status === 'required' && !isNagComplete(nag))
.length === 0
)
return validateProject(nagContext.value).valid
})
async function submitForReview() {
@@ -354,7 +348,7 @@ async function submitForReview() {
}
const applicableNags = computed<Nag[]>(() => {
return nags.filter((nag) => {
return getNags(nagContext.value).filter((nag) => {
return nag.shouldShow(nagContext.value)
})
})
@@ -390,9 +384,8 @@ const visibleNags = computed<Nag[]>(() => {
status: 'special-submit-action',
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
path: 'moderation',
...nagDestinations.moderation,
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== 'type-project-moderation',
},
})
}
@@ -408,7 +401,7 @@ const visibleNags = computed<Nag[]>(() => {
watch(visibleNags, () => nextTick(updateNagScrollShadows))
function shouldShowLink(nag: Nag): boolean {
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false
return nag.link?.shouldShow(nagContext.value) ?? false
}
function getDefaultIcon(status: NagStatus): Component {
@@ -1,12 +1,13 @@
import {
extractProjectLinks,
type FieldValidationMessage,
findBlockedProjectContentLink,
type LinkCheckContext,
type LinkCheckResult,
type ProjectTextValidationResult,
validateLink,
validateProjectDescription,
validateProjectNameField,
validateProjectSummary,
validateProjectTitle,
} from '@modrinth/moderation'
import { defineMessages } from '@modrinth/ui'
import { computed, type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
@@ -19,14 +20,19 @@ export const projectTextValidationMessages = defineMessages({
})
export function useProjectTitleValidation(text: MaybeRefOrGetter<string | null | undefined>) {
return computed(() => validateProjectTitle(toValue(text)))
return computed(() => validateProjectNameField(toValue(text) ?? ''))
}
export function useProjectSummaryValidation(
summary: MaybeRefOrGetter<string | null | undefined>,
title: MaybeRefOrGetter<string | null | undefined>,
) {
return computed(() => validateProjectSummary(toValue(summary), toValue(title)))
return computed(() =>
validateProjectSummary({
summary: toValue(summary),
name: toValue(title),
}),
)
}
export function useLinkValidation(context: MaybeRefOrGetter<LinkCheckContext>) {
@@ -86,7 +92,7 @@ export function useProjectDescriptionValidation(
linkValidation.value = null
if (import.meta.server) return
if (validateProjectDescription(text).some(({ code }) => code === 'text-banned-link')) {
if (findBlockedProjectContentLink(text ?? '')) {
pending.value = false
return
}
@@ -130,7 +136,7 @@ export function useProjectDescriptionValidation(
requestId++
})
const validation = computed<Array<ProjectTextValidationResult | LinkCheckResult>>(() => [
const validation = computed<Array<FieldValidationMessage | LinkCheckResult>>(() => [
...validateProjectDescription(toValue(description)),
...(linkValidation.value ? [linkValidation.value] : []),
])
@@ -298,7 +298,7 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { validateProjectText } from '@modrinth/moderation'
import { validateProjectGalleryDescription, validateProjectGalleryName } from '@modrinth/moderation'
import {
Button,
ButtonLink,
@@ -384,8 +384,10 @@ const previewImage = ref<string | null>(null)
// UI state
const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryTitleValidation = computed(() => validateProjectGalleryName(editTitle.value))
const galleryDescriptionValidation = computed(() =>
validateProjectGalleryDescription(editDescription.value),
)
const galleryFieldsInvalid = computed(
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
@@ -288,7 +288,7 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { validateProjectText } from '@modrinth/moderation'
import { validateProjectGalleryDescription, validateProjectGalleryName } from '@modrinth/moderation'
import {
Button,
ButtonLink,
@@ -344,8 +344,10 @@ const editOrder = ref(null)
const editFile = ref(null)
const previewImage = ref(null)
const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryTitleValidation = computed(() => validateProjectGalleryName(editTitle.value))
const galleryDescriptionValidation = computed(() =>
validateProjectGalleryDescription(editDescription.value),
)
const galleryFieldsInvalid = computed(
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
@@ -15,8 +15,8 @@ import {
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import ValidationMessage from '~/components/ValidationMessage.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import {
useProjectSummaryValidation,
useProjectTitleValidation,
@@ -215,6 +215,7 @@ const router = useRouter()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const debugValidationFilter = useDebugLogger('moderation-validation-filter')
const tags = useGeneratedState()
const queueSummaryModal = ref()
const moderateByIdsModal = ref<InstanceType<typeof ModerateByIdsModal>>()
@@ -566,6 +567,7 @@ const {
client,
request: queryKey[3],
includeWarnings: queryKey[2],
tags: tags.value,
signal,
log: debugValidationFilter,
})
@@ -1,5 +1,5 @@
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
import { validateProjectFields } from '@modrinth/moderation'
import { type ProjectValidationContext, validateProject } from '@modrinth/moderation'
export type ValidationFilterRequest = Omit<
Labrinth.Moderation.Internal.ProjectsRequest,
@@ -17,6 +17,7 @@ interface ValidationFilterScanOptions {
client: AbstractModrinthClient
request: ValidationFilterRequest
includeWarnings: boolean
tags: ProjectValidationContext['tags']
signal: AbortSignal
log: (message: string) => void
}
@@ -107,6 +108,7 @@ export async function scanProjectsWithValidationIssues({
client,
request,
includeWarnings,
tags,
signal,
log,
}: ValidationFilterScanOptions): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
@@ -129,24 +131,69 @@ export async function scanProjectsWithValidationIssues({
const projectIds = queueProjects
.slice(batchIndex * PROJECT_BATCH_SIZE, (batchIndex + 1) * PROJECT_BATCH_SIZE)
.map((project) => project.id)
const projects = await pacedFetcher.fetch(
const projectsV3 = await pacedFetcher.fetch(
`Fetching V3 project batch ${batchIndex + 1}/${projectBatchCount}`,
() => client.labrinth.projects_v3.getMultiple(projectIds),
)
const projectsById = new Map(projects.map((project) => [project.id, project]))
const missingProjectIds = projectIds.filter((projectId) => !projectsById.has(projectId))
const projectsV2 = await pacedFetcher.fetch(
`Fetching V2 project batch ${batchIndex + 1}/${projectBatchCount}`,
() => client.labrinth.projects_v2.getMultiple(projectIds),
)
const projectsV3ById = new Map(projectsV3.map((project) => [project.id, project]))
const projectsV2ById = new Map(projectsV2.map((project) => [project.id, project]))
const missingProjectIds = projectIds.filter(
(projectId) => !projectsV3ById.has(projectId) || !projectsV2ById.has(projectId),
)
if (missingProjectIds.length > 0) {
throw new Error(`V3 projects response omitted ${missingProjectIds.length} queued projects`)
throw new Error(`Project responses omitted ${missingProjectIds.length} queued projects`)
}
const versionIds = [...new Set(projectsV3.flatMap((project) => project.versions))]
const versions: Labrinth.Versions.v3.Version[] = []
const versionBatchCount = Math.ceil(versionIds.length / PROJECT_BATCH_SIZE)
for (let versionBatchIndex = 0; versionBatchIndex < versionBatchCount; versionBatchIndex++) {
const batchVersionIds = versionIds.slice(
versionBatchIndex * PROJECT_BATCH_SIZE,
(versionBatchIndex + 1) * PROJECT_BATCH_SIZE,
)
versions.push(
...(await pacedFetcher.fetch(
`Fetching version batch ${versionBatchIndex + 1}/${versionBatchCount}`,
() => client.labrinth.versions_v3.getVersions(batchVersionIds),
)),
)
}
const versionsById = new Map(versions.map((version) => [version.id, version]))
const missingVersionIds = versionIds.filter((versionId) => !versionsById.has(versionId))
if (missingVersionIds.length > 0) {
throw new Error(`Version responses omitted ${missingVersionIds.length} project versions`)
}
for (const projectId of projectIds) {
const project = projectsById.get(projectId)
if (!project) {
throw new Error(`V3 projects response omitted queued project ${projectId}`)
const projectV3 = projectsV3ById.get(projectId)
const rawProjectV2 = projectsV2ById.get(projectId)
if (!projectV3 || !rawProjectV2) {
throw new Error(`Project responses omitted queued project ${projectId}`)
}
const validation = validateProjectFields(project)
if (includeWarnings ? validation.failures.length > 0 : !validation.valid) {
const project = {
...rawProjectV2,
actualProjectType: rawProjectV2.project_type,
}
const projectVersions = projectV3.versions.flatMap((versionId) => {
const version = versionsById.get(versionId)
return version ? [version] : []
})
const validation = validateProject({
project,
projectV3,
versions: projectVersions,
tags,
})
if (
validation.requiredNags.length > 0 ||
(includeWarnings && validation.warningNags.length > 0)
) {
matchingProjectIds.add(projectId)
}
}