mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
feat: hook up validators with input fields
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user