feat: hook up validators with input fields

This commit is contained in:
tdgao
2026-08-24 16:19:49 -07:00
committed by Prospector
parent e618c1625a
commit eb22882138
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()