mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
refactor: centralize project validation and nags into validation rules
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user