feat: tags validations

This commit is contained in:
Calum 2025-07-13 15:01:43 +01:00
parent 27caf336cc
commit debcb57f47
3 changed files with 211 additions and 139 deletions

View File

@ -6,11 +6,31 @@
<span class="label__title size-card-header">Tags</span> <span class="label__title size-card-header">Tags</span>
</h3> </h3>
</div> </div>
<div
v-if="tooManyTagsWarning && !allTagsSelectedWarning"
class="my-2 flex items-center gap-1.5 text-orange"
>
<TriangleAlertIcon class="my-auto" />
{{ tooManyTagsWarning }}
</div>
<div v-if="multipleResolutionTagsWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ multipleResolutionTagsWarning }}
</div>
<div v-if="allTagsSelectedWarning" class="my-2 flex items-center gap-1.5 text-red">
<TriangleAlertIcon class="my-auto" />
<span>{{ allTagsSelectedWarning }}</span>
</div>
<p> <p>
Accurate tagging is important to help people find your Accurate tagging is important to help people find your
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags {{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply. that apply.
</p> </p>
<p v-if="project.versions.length === 0" class="known-errors"> <p v-if="project.versions.length === 0" class="known-errors">
Please upload a version first in order to select tags! Please upload a version first in order to select tags!
</p> </p>
@ -112,70 +132,69 @@
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import { StarIcon, SaveIcon } from "@modrinth/assets"; import { computed, ref } from "vue";
import { formatCategory, formatCategoryHeader, formatProjectType } from "@modrinth/utils"; import { StarIcon, SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import {
formatCategory,
formatCategoryHeader,
formatProjectType,
sortedCategories,
type Project,
} from "@modrinth/utils";
import Checkbox from "~/components/ui/Checkbox.vue"; import Checkbox from "~/components/ui/Checkbox.vue";
export default defineNuxtComponent({ interface Category {
components: { name: string;
Checkbox, header: string;
SaveIcon, icon?: string;
StarIcon, project_type: string;
}, }
props: {
project: { interface Props {
type: Object, project: Project & {
default() { actualProjectType: string;
return {}; };
}, allMembers?: any[];
}, currentMember?: any;
allMembers: { patchProject?: (data: any) => void;
type: Array, }
default() {
return []; const tags = useTags();
},
}, const props = withDefaults(defineProps<Props>(), {
currentMember: { allMembers: () => [],
type: Object, currentMember: null,
default() { patchProject: () => {
return null; addNotification({
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: "main",
title: "An error occurred", title: "An error occurred",
text: "Patch project function not found", text: "Patch project function not found",
type: "error", type: "error",
}); });
};
}, },
}, });
},
data() { const selectedTags = ref<Category[]>(
return { sortedCategories(tags.value).filter(
selectedTags: this.$sortedCategories().filter( (x: Category) =>
(x) => x.project_type === props.project.actualProjectType &&
x.project_type === this.project.actualProjectType && (props.project.categories.includes(x.name) ||
(this.project.categories.includes(x.name) || props.project.additional_categories.includes(x.name)),
this.project.additional_categories.includes(x.name)),
), ),
featuredTags: this.$sortedCategories().filter( );
(x) =>
x.project_type === this.project.actualProjectType && const featuredTags = ref<Category[]>(
this.project.categories.includes(x.name), sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
props.project.categories.includes(x.name),
), ),
}; );
},
computed: { const categoryLists = computed(() => {
categoryLists() { const lists: Record<string, Category[]> = {};
const lists = {}; sortedCategories(tags.value).forEach((x: Category) => {
this.$sortedCategories().forEach((x) => { if (x.project_type === props.project.actualProjectType) {
if (x.project_type === this.project.actualProjectType) {
const header = x.header; const header = x.header;
if (!lists[header]) { if (!lists[header]) {
lists[header] = []; lists[header] = [];
@ -184,73 +203,111 @@ export default defineNuxtComponent({
} }
}); });
return lists; return lists;
}, });
patchData() {
const data = {}; // Warning computeds based on the nags
const tooManyTagsWarning = computed(() => {
const tagCount = selectedTags.value.length;
if (tagCount > 5) {
return `You've selected ${tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.`;
}
return null;
});
const multipleResolutionTagsWarning = computed(() => {
if (props.project.project_type !== "resourcepack") return null;
const resolutionTags = selectedTags.value.filter((tag) =>
["16x", "32x", "48x", "64x", "128x", "256x", "512x", "1024x"].includes(tag.name),
);
if (resolutionTags.length > 1) {
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags.map((t) => t.name).join(", ")}). Resource packs should typically only have one resolution tag.`;
}
return null;
});
const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === props.project.actualProjectType,
);
const totalSelectedTags = selectedTags.value.length;
if (
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
) {
return `You've selected all ${categoriesForProjectType.length} available tags. Please select only the tags that truly apply to your project.`;
}
return null;
});
const patchData = computed(() => {
const data: Record<string, string[]> = {};
// Promote selected categories to featured if there are less than 3 featured // Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = this.featuredTags.slice(); const newFeaturedTags = featuredTags.value.slice();
if (newFeaturedTags.length < 1 && this.selectedTags.length > newFeaturedTags.length) { if (newFeaturedTags.length < 1 && selectedTags.value.length > newFeaturedTags.length) {
const nonFeaturedCategories = this.selectedTags.filter((x) => !newFeaturedTags.includes(x)); const nonFeaturedCategories = selectedTags.value.filter((x) => !newFeaturedTags.includes(x));
nonFeaturedCategories nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length)) .slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x)); .forEach((x) => newFeaturedTags.push(x));
} }
// Convert selected and featured categories to backend-usable arrays // Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name); const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = this.selectedTags const additionalCategories = selectedTags.value
.filter((x) => !newFeaturedTags.includes(x)) .filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name); .map((x) => x.name);
if ( if (
categories.length !== this.project.categories.length || categories.length !== props.project.categories.length ||
categories.some((value) => !this.project.categories.includes(value)) categories.some((value) => !props.project.categories.includes(value))
) { ) {
data.categories = categories; data.categories = categories;
} }
if ( if (
additionalCategories.length !== this.project.additional_categories.length || additionalCategories.length !== props.project.additional_categories.length ||
additionalCategories.some((value) => !this.project.additional_categories.includes(value)) additionalCategories.some((value) => !props.project.additional_categories.includes(value))
) { ) {
data.additional_categories = additionalCategories; data.additional_categories = additionalCategories;
} }
return data; return data;
},
hasChanges() {
return Object.keys(this.patchData).length > 0;
},
},
methods: {
formatProjectType,
formatCategoryHeader,
formatCategory,
toggleCategory(category) {
if (this.selectedTags.includes(category)) {
this.selectedTags = this.selectedTags.filter((x) => x !== category);
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
}
} else {
this.selectedTags.push(category);
}
},
toggleFeaturedCategory(category) {
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
} else {
this.featuredTags.push(category);
}
},
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
},
}); });
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0;
});
const toggleCategory = (category: Category) => {
if (selectedTags.value.includes(category)) {
selectedTags.value = selectedTags.value.filter((x) => x !== category);
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
}
} else {
selectedTags.value.push(category);
}
};
const toggleFeaturedCategory = (category: Category) => {
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
} else {
featuredTags.value.push(category);
}
};
const saveChanges = () => {
if (hasChanges.value) {
props.patchProject(patchData.value);
}
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.label__title { .label__title {
display: flex; display: flex;

View File

@ -2,5 +2,6 @@ import type { Nag } from '../types/nags'
import { coreNags } from './nags/core' import { coreNags } from './nags/core'
import { descriptionNags } from './nags/description' import { descriptionNags } from './nags/description'
import { linksNags } from './nags/links' import { linksNags } from './nags/links'
import { tagsNags } from './nags/tags'
export default [...coreNags, ...linksNags, ...descriptionNags] as Nag[] export default [...coreNags, ...linksNags, ...descriptionNags, ...tagsNags] as Nag[]

View File

@ -1,16 +1,27 @@
import type { Nag, NagContext } from '../../types/nags' import type { Nag, NagContext } from '../../types/nags'
function getCategories(project: any, tags: any) {
return (
tags.categories?.filter(
(category: any) => category.project_type === project.actualProjectType,
) ?? []
)
}
export const tagsNags: Nag[] = [ export const tagsNags: Nag[] = [
{ {
id: 'too-many-tags', id: 'too-many-tags',
title: 'Too many tags selected', title: 'Too many tags selected',
description: (context: NagContext) => { description: (context: NagContext) => {
const tagCount = context.project.categories.length const tagCount =
return `You've selected ${tagCount} tags. Consider reducing to 3 or fewer to keep your project focused and easier to discover.` context.project.categories.length + context.project.additional_categories?.length || 0
return `You've selected ${tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.`
}, },
status: 'warning', status: 'warning',
shouldShow: (context: NagContext) => { shouldShow: (context: NagContext) => {
return context.project.categories.length > 3 const tagCount =
context.project.categories.length + context.project.additional_categories?.length || 0
return tagCount > 5
}, },
link: { link: {
path: 'settings/tags', path: 'settings/tags',
@ -46,14 +57,17 @@ export const tagsNags: Nag[] = [
id: 'all-tags-selected', id: 'all-tags-selected',
title: 'All tags selected', title: 'All tags selected',
description: (context: NagContext) => { description: (context: NagContext) => {
const totalAvailableTags = context.tags.categories?.length || 0 const categoriesForProjectType = getCategories(context.project, context.tags)
console.log('categoriesForProjectType', categoriesForProjectType)
const totalAvailableTags = categoriesForProjectType.length
return `You've selected all ${totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project.` return `You've selected all ${totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project.`
}, },
status: 'required', status: 'required',
shouldShow: (context: NagContext) => { shouldShow: (context: NagContext) => {
const totalAvailableTags = context.tags.categories?.length || 0 const categoriesForProjectType = getCategories(context.project, context.tags)
const selectedTags = context.project.categories.length const totalSelectedTags =
return totalAvailableTags > 0 && selectedTags === totalAvailableTags context.project.categories.length + (context.project.additional_categories?.length || 0)
return totalSelectedTags === categoriesForProjectType.length
}, },
link: { link: {
path: 'settings/tags', path: 'settings/tags',