feat: hook up validators with input fields

This commit is contained in:
tdgao
2026-08-24 16:07:11 -06:00
parent 1ec4c6cef8
commit a23df8a355
12 changed files with 156 additions and 23 deletions
@@ -41,6 +41,7 @@
:disabled="hasHitLimit"
@update:model-value="updatedName()"
/>
<ValidationMessage :check="nameValidation" />
</div>
<label for="slug" class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">
@@ -108,6 +109,7 @@
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
<ValidationMessage :check="summaryValidation" />
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</div>
<div class="flex justify-end gap-2.5">
@@ -148,6 +150,8 @@ import {
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { validateProjectText } from '~/composables/project-text-validation'
import { generateUrlSlug } from '~/utils/slugs'
import CreateLimitAlert from './CreateLimitAlert.vue'
@@ -303,8 +307,12 @@ const visibilities = ref<VisibilityOption[]>([
])
const visibility = ref<VisibilityOption>(visibilities.value[0])
const nameValidation = computed(() => validateProjectText(name.value))
const summaryValidation = computed(() => validateProjectText(description.value))
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (nameValidation.value || summaryValidation.value) return true
if (!name.value.trim() || !slug.value.trim()) return true
if (description.value.trim().length < 3) return true
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
@@ -392,6 +400,7 @@ async function fetchOrganizations() {
}
async function createProject() {
if (disableCreate.value) return
startLoading()
const formData = new FormData()
@@ -0,0 +1,42 @@
import { validateNonStandardText, validateProfanity } from '@modrinth/moderation'
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
export interface ProjectTextValidationResult {
severity: 'error'
message: MessageDescriptor
}
const messages = defineMessages({
slur: {
id: 'project.text-validation.slur',
defaultMessage: 'Slurs are not allowed.',
},
profanity: {
id: 'project.text-validation.profanity',
defaultMessage: 'Profanity is not allowed.',
},
nonStandardText: {
id: 'project.text-validation.non-standard-text',
defaultMessage: 'Non-standard text characters are not allowed.',
},
})
export function validateProjectText(
text: string | null | undefined,
): ProjectTextValidationResult | null {
if (!text) return null
const profanity = validateProfanity(text)
if (profanity.slurCount > 0) {
return { severity: 'error', message: messages.slur }
}
if (profanity.profanityCount > 0) {
return { severity: 'error', message: messages.profanity }
}
if (!validateNonStandardText(text).valid) {
return { severity: 'error', message: messages.nonStandardText }
}
return null
}
@@ -17,13 +17,10 @@
</div>
<MarkdownEditor
v-model="current.description"
:disabled="
!currentMember ||
(currentMember?.permissions! & TeamMemberPermission.EDIT_BODY) !==
TeamMemberPermission.EDIT_BODY
"
:disabled="!hasPermission"
:on-image-upload="onUploadHandler"
/>
<ValidationMessage :check="descriptionValidation" class="mt-2" />
<div v-if="descriptionWarning" class="mt-2">
<SettingsInlineWarning>
{{ descriptionWarning }}
@@ -34,6 +31,7 @@
:original="saved"
:modified="current"
:saving="saving"
:can-save="canSave"
@reset="reset"
@save="save"
/>
@@ -56,7 +54,9 @@ import { TeamMemberPermission } from '@modrinth/utils'
import { computed, useTemplateRef } from 'vue'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { validateProjectText } from '~/composables/project-text-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
@@ -64,7 +64,14 @@ const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.description)
const { saved, current, saving, hasChanges, reset, save } = useSavable(
const {
saved,
current,
saving,
hasChanges,
reset,
save: saveForm,
} = useSavable(
() => ({ description: project.value.body }),
async ({ description }) => {
await patchProject({ body: description })
@@ -73,6 +80,20 @@ const { saved, current, saving, hasChanges, reset, save } = useSavable(
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const hasPermission = computed(
() =>
!!currentMember.value &&
(currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
TeamMemberPermission.EDIT_BODY,
)
const descriptionValidation = computed(() => validateProjectText(current.value.description))
const canSave = computed(() => hasPermission.value && !descriptionValidation.value)
async function save() {
if (!canSave.value) return
await saveForm()
}
const descriptionWarning = computed(() => {
const text = current.value.description?.trim() || ''
const charCount = countText(text)
@@ -14,13 +14,23 @@ import {
useVIntl,
} from '@modrinth/ui'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { validateProjectText } from '~/composables/project-text-validation'
const { formatMessage } = useVIntl()
const { projectV2: project, patchProject } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
const { saved, current, saving, hasChanges, reset, save } = useSavable(
const {
saved,
current,
saving,
hasChanges,
reset,
save: saveForm,
} = useSavable(
() => ({
title: project.value.title,
tagline: project.value.description,
@@ -38,6 +48,15 @@ const { saved, current, saving, hasChanges, reset, save } = useSavable(
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const titleValidation = computed(() => validateProjectText(current.value.title))
const taglineValidation = computed(() => validateProjectText(current.value.tagline))
const canSave = computed(() => !titleValidation.value && !taglineValidation.value)
async function save() {
if (!canSave.value) return
await saveForm()
}
const messages = defineMessages({
nameTitle: {
id: 'project.settings.general.name.title',
@@ -129,6 +148,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
:original="saved"
:modified="current"
:saving="saving"
:can-save="canSave"
@reset="reset"
@save="save"
/>
@@ -152,6 +172,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
wrapper-class="flex-grow"
/>
</div>
<ValidationMessage :check="titleValidation" class="mt-2" />
</div>
<div class="mt-4">
<SettingsLabel
@@ -167,6 +188,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
:maxlength="120"
wrapper-class="w-full"
/>
<ValidationMessage :check="taglineValidation" class="mt-2" />
</div>
<div class="mt-4">
<SettingsLabel id="project-url" :title="messages.urlTitle" />
@@ -28,6 +28,7 @@
:maxlength="2048"
:disabled="!hasPermission"
/>
<ValidationMessage :check="nameValidation" class="mt-2" />
</div>
<div>
@@ -60,6 +61,7 @@
:disabled="!hasPermission"
resize="vertical"
/>
<ValidationMessage :check="summaryValidation" class="mt-2" />
<div v-if="summaryWarning" class="my-2">
<SettingsInlineWarning>
{{ summaryWarning }}
@@ -289,6 +291,7 @@
:original="original"
:modified="modified"
:saving="saving"
:can-save="canSave"
@reset="resetChanges"
@save="handleSave"
/>
@@ -325,7 +328,9 @@ import {
import { fileIsValid, formatProjectStatus } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useAuth } from '~/composables/auth.js'
import { validateProjectText } from '~/composables/project-text-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
@@ -397,6 +402,12 @@ const hasPermission = computed(() => {
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
})
const nameValidation = computed(() => validateProjectText(name.value))
const summaryValidation = computed(() => validateProjectText(summary.value))
const canSave = computed(
() => hasPermission.value && !nameValidation.value && !summaryValidation.value,
)
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
const hasDeletePermission = computed(() => {
@@ -529,6 +540,7 @@ async function updateMonetizationStatus(status) {
}
async function handleSave() {
if (!canSave.value) return
saving.value = true
try {
const hasPatchChanges = Object.keys(basePatchData.value).length > 0
@@ -84,7 +84,7 @@
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
wrapper-class="w-full"
/>
<LinkCheckMessage :check="effectiveLicenseCheck" />
<ValidationMessage :check="effectiveLicenseCheck" />
</div>
</div>
@@ -165,7 +165,7 @@ import {
import { builtinLicenses, formatProjectType, TeamMemberPermission } from '@modrinth/utils'
import { computed } from 'vue'
import LinkCheckMessage from '@/components/LinkCheckMessage.vue'
import ValidationMessage from '@/components/ValidationMessage.vue'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
@@ -17,7 +17,7 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="siteCheck" />
<ValidationMessage :check="siteCheck" />
</div>
<div class="adjacent-input">
<label id="server-store" title="Your server's store page.">
@@ -32,7 +32,7 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="storeCheck" />
<ValidationMessage :check="storeCheck" />
</div>
<div class="adjacent-input">
<label
@@ -52,7 +52,7 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="wikiCheck" />
<ValidationMessage :check="wikiCheck" />
</div>
<div class="adjacent-input">
<label id="server-discord" title="An invitation link to your Discord server.">
@@ -67,7 +67,7 @@
maxlength="2048"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="discordInviteCheck" />
<ValidationMessage :check="discordInviteCheck" />
</div>
</section>
@@ -92,7 +92,7 @@
:maxlength="2048"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="issuesCheck" />
<ValidationMessage :check="issuesCheck" />
</div>
<div class="adjacent-input">
<label
@@ -112,7 +112,7 @@
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="sourceCheck" />
<ValidationMessage :check="sourceCheck" />
</div>
<div class="adjacent-input">
<label
@@ -132,7 +132,7 @@
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="wikiCheck" />
<ValidationMessage :check="wikiCheck" />
</div>
<div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server.">
@@ -147,7 +147,7 @@
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
<LinkCheckMessage :check="discordInviteCheck" />
<ValidationMessage :check="discordInviteCheck" />
</div>
<span class="label">
<span class="label__title">Donation links</span>
@@ -179,7 +179,7 @@
class="platform-selector !w-80"
@update:model-value="updateDonationLinks"
/>
<LinkCheckMessage :check="donationCheckState(donationLink, index)" />
<ValidationMessage :check="donationCheckState(donationLink, index)" />
</div>
</section>
<UnsavedChangesPopup
@@ -214,7 +214,7 @@ import {
useSavable,
} from '@modrinth/ui'
import LinkCheckMessage from '@/components/LinkCheckMessage.vue'
import ValidationMessage from '@/components/ValidationMessage.vue'
const tags = useGeneratedState()
@@ -47,6 +47,7 @@ const PRIVATE_USE_PATTERN = /\p{Co}/u
const UNASSIGNED_PATTERN = /\p{Cn}/u
const LETTER_PATTERN = /\p{L}/u
const EXTENDED_PICTOGRAPHIC_PATTERN = /\p{Extended_Pictographic}/u
const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u
const UNIFIED_IDEOGRAPH_PATTERN = /\p{Unified_Ideograph}/u
function createCounts(): Record<NonStandardTextIssueKind, number> {
@@ -75,6 +76,10 @@ function isEmojiModifier(codePoint: number) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff
}
function isEmojiTag(codePoint: number) {
return codePoint >= 0xe0020 && codePoint <= 0xe007f
}
function isAscii(character: string) {
return character.codePointAt(0)! <= 0x7f
}
@@ -129,6 +134,24 @@ function isAllowedVariationSelector(
return UNIFIED_IDEOGRAPH_PATTERN.test(previous)
}
function isAllowedEmojiTagSequence(characters: readonly string[], characterIndex: number) {
let start = characterIndex - 1
while (start >= 0 && isEmojiTag(characters[start].codePointAt(0)!)) start--
if (characters[start]?.codePointAt(0) !== 0x1f3f4) return false
let end = characterIndex
while (end < characters.length && isEmojiTag(characters[end].codePointAt(0)!)) end++
return characters[end - 1]?.codePointAt(0) === 0xe007f
}
function isPresentedAsEmoji(characters: readonly string[], characterIndex: number) {
const character = characters[characterIndex]
return (
EMOJI_PRESENTATION_PATTERN.test(character) ||
characters[characterIndex + 1]?.codePointAt(0) === 0xfe0f
)
}
function codePointLabel(codePoint: number) {
return `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
}
@@ -200,7 +223,8 @@ export function validateNonStandardText(
if (FORMAT_PATTERN.test(character)) {
const allowed =
(codePoint === 0x200c && isAllowedZeroWidthNonJoiner(characters, characterIndex)) ||
(codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex))
(codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex)) ||
(isEmojiTag(codePoint) && isAllowedEmojiTagSequence(characters, characterIndex))
if (!allowed) addIssue('invisible', character, codePoint, currentIndex)
hasBaseCharacter = false
combiningMarkCount = 0
@@ -225,7 +249,7 @@ export function validateNonStandardText(
combiningMarkCount = 0
hasBaseCharacter = !/^\s$/u.test(character)
if (isInRanges(codePoint, FANCY_RANGES)) {
if (isInRanges(codePoint, FANCY_RANGES) && !isPresentedAsEmoji(characters, characterIndex)) {
addIssue('fancy', character, codePoint, currentIndex)
}
}
@@ -57,6 +57,9 @@ test('allows ordinary emoji and valid emoji joiner sequences', () => {
assert.equal(validateNonStandardText('Family: 👨‍👩‍👧‍👦').valid, true)
assert.equal(validateNonStandardText('Developer: 🧑🏽‍💻').valid, true)
assert.equal(validateNonStandardText('Heart: ❤️').valid, true)
assert.equal(validateNonStandardText('Information: ️').valid, true)
assert.equal(validateNonStandardText('A button: 🅰️').valid, true)
assert.equal(validateNonStandardText('Scotland: 🏴󠁧󠁢󠁳󠁣󠁴󠁿').valid, true)
})
test('detects suspicious invisible and directional characters', () => {
@@ -499,7 +499,7 @@ function findAt(root: TrieNode, text: string, start: number): ProfanityMatch | u
const matchesNegative = current.negatives.some((negative) =>
negativeMatches(negative, text, start, end),
)
if (matchesNegative && current.children.size === 0) return undefined
if (matchesNegative) continue
return {
kind: current.terminal.kind,
@@ -57,7 +57,7 @@ test('uses the first terminal when one configured term prefixes another', () =>
})
test('does not match configured false-positive substrings', () => {
assert.equal(validateProfanity('Scunthorpe and peacock').valid, true)
assert.equal(validateProfanity('Scunthorpe, Clitheroe, and peacock').valid, true)
assert.equal(validateProfanity('cock and cunt').profanityCount, 2)
})