feat: implement depends on search filter

This commit is contained in:
tdgao
2026-07-13 14:23:58 -07:00
parent 3ac3b9539d
commit 39b4cf62ea
8 changed files with 192 additions and 10 deletions
@@ -32,12 +32,13 @@ export type ProjectType =
| 'plugin'
| 'server'
interface SearchHit {
export interface SearchHit {
project_id: string
title: string
icon_url?: string
project_type: string
slug: string
author?: string
}
const props = withDefaults(
@@ -37,13 +37,16 @@
<script setup lang="ts">
import { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import { computed, type ComputedRef } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
@@ -58,6 +61,10 @@ const defaultProvidedMessage = defineMessage({
id: 'search.filter.locked.default',
defaultMessage: 'Filter locked',
})
const dependentProjectMessage = defineMessage({
id: 'search.filter.dependent_project',
defaultMessage: 'Depends on: {project}',
})
type Item = {
type: string
@@ -67,13 +74,39 @@ type Item = {
provided: boolean
}
const dependentProjectIds = computed(() =>
[
...new Set(
[...selectedFilters.value, ...props.providedFilters]
.filter((filter) => filter.type === 'compatible_dependency_project_ids')
.map((filter) => filter.option),
),
].sort(),
)
const { data: dependentProjects } = useQuery({
queryKey: computed(() => [
'search-filter-control',
'dependent-projects',
dependentProjectIds.value,
]),
queryFn: () => labrinth.projects_v2.getMultiple(dependentProjectIds.value),
enabled: computed(() => dependentProjectIds.value.length > 0),
placeholderData: [],
refetchOnWindowFocus: false,
})
const dependentProjectNames = computed(
() => new Map(dependentProjects.value?.map((project) => [project.id, project.title]) ?? []),
)
function filterMatches(type: FilterType, option: FilterOption, list: FilterValue[]) {
return list.some((provided) => provided.type === type.id && provided.option === option.id)
}
const items: ComputedRef<Item[]> = computed(() => {
return props.filters.flatMap((type) =>
type.options
return props.filters.flatMap((type) => {
const optionItems = type.options
.filter(
(option) =>
filterMatches(type, option, selectedFilters.value) ||
@@ -86,8 +119,30 @@ const items: ComputedRef<Item[]> = computed(() => {
?.negative,
provided: filterMatches(type, option, props.providedFilters),
formatted_name: option.formatted_name,
}))
if (type.id !== 'compatible_dependency_project_ids') {
return optionItems
}
const customValues = [...selectedFilters.value, ...props.providedFilters].filter(
(filter) => filter.type === type.id,
)
return [
...optionItems,
...customValues.map((filter) => ({
type: type.id,
option: filter.option,
negative: filter.negative,
provided: props.providedFilters.some(
(provided) => provided.type === type.id && provided.option === filter.option,
),
formatted_name: formatMessage(dependentProjectMessage, {
project: dependentProjectNames.value.get(filter.option) ?? filter.option,
}),
})),
)
]
})
})
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
@@ -71,6 +71,48 @@
</template>
<template v-else #default>
<slot name="prefix" />
<div
v-if="filterType.display === 'project'"
:class="innerPanelClass ? innerPanelClass : ''"
class="flex flex-col gap-3"
>
<ProjectCombobox
v-show="!selectedProjectId || refreshing"
ref="projectCombobox"
search-placeholder="Search for a project..."
:model-value="selectedProjectId"
:project-types="selectableProjectTypes"
@update:model-value="setSelectedProjectId"
/>
<template v-if="selectedProjectId && !refreshing">
<div class="flex items-center justify-between gap-3 px-2 text-secondary">
<span>{{ formatMessage(messages.dependentCount, { count: resultCount ?? 0 }) }}</span>
<button
class="border-none bg-transparent p-0 text-secondary cursor-pointer hover:text-contrast"
@click="setSelectedProjectId(undefined)"
>
{{ formatMessage(messages.clearFilter) }}
</button>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-1 p-3">
<img
v-if="selectedProject?.icon_url"
:src="selectedProject.icon_url"
:alt="selectedProject.title"
class="size-14 shrink-0 rounded-xl object-cover"
/>
<PackageIcon v-else class="size-14 shrink-0 text-secondary" />
<div class="min-w-0">
<div class="truncate text-base font-bold text-contrast">
{{ selectedProject?.title ?? selectedProjectId }}
</div>
<div v-if="selectedProject?.author" class="truncate text-sm text-secondary">
{{ selectedProject.author }}
</div>
</div>
</div>
</template>
</div>
<div
v-if="filterType.display === 'toggle'"
:class="innerPanelClass ? innerPanelClass : ''"
@@ -90,7 +132,7 @@
/>
</label>
</div>
<template v-if="filterType.display !== 'toggle'">
<template v-if="filterType.display !== 'toggle' && filterType.display !== 'project'">
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
@@ -206,7 +248,14 @@
</template>
<script setup lang="ts">
import { BanIcon, DropdownIcon, LockOpenIcon, SearchIcon, UpdatedIcon } from '@modrinth/assets'
import {
BanIcon,
DropdownIcon,
LockOpenIcon,
PackageIcon,
SearchIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
@@ -215,6 +264,10 @@ import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Toggle from '../base/Toggle.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import ProjectCombobox, {
type ProjectType as ProjectComboboxProjectType,
type SearchHit,
} from '../project/ProjectCombobox.vue'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
@@ -234,6 +287,8 @@ const props = defineProps<{
innerPanelClass?: string
openByDefault?: boolean
providedFilters: FilterValue[]
resultCount?: number
refreshing?: boolean
}>()
defineOptions({
@@ -244,6 +299,26 @@ const query = ref('')
const showMore = ref(false)
const accordion = ref<InstanceType<typeof Accordion> | null>()
const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null)
const selectableProjectTypes: ProjectComboboxProjectType[] = [
'mod',
'modpack',
'resourcepack',
'shader',
'datapack',
'plugin',
]
const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null)
const selectedProjectId = computed(
() => selectedFilters.value.find((filter) => filter.type === props.filterType.id)?.option,
)
function setSelectedProjectId(projectId: string | undefined) {
const otherFilters = selectedFilters.value.filter((filter) => filter.type !== props.filterType.id)
selectedFilters.value = projectId
? [...otherFilters, { type: props.filterType.id, option: projectId }]
: otherFilters
}
const selectedFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
@@ -397,6 +472,14 @@ function clearFilters() {
}
const messages = defineMessages({
clearFilter: {
id: 'search.filter.clear',
defaultMessage: 'Clear',
},
dependentCount: {
id: 'search.filter.dependent_count',
defaultMessage: '{count, plural, one {# dependent} other {# dependents}}',
},
searchPlaceholder: {
id: 'search.filter.option.search.placeholder',
defaultMessage: 'Search...',
@@ -48,6 +48,7 @@ export interface BrowseSearchState {
effectiveCurrentSortType: Ref<SortType>
loading: Ref<boolean>
refreshing: Ref<boolean>
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
totalHits: Ref<number>
@@ -172,6 +173,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
])
const loading = ref(true)
const refreshing = ref(false)
const projectHits = shallowRef<BrowseSearchResponse['projectHits']>([])
const serverHits = shallowRef<BrowseSearchResponse['serverHits']>([])
const totalHits = ref(0)
@@ -205,6 +207,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
)
watch(effectiveRequestParams, (newVal, oldVal) => {
refreshing.value = true
debug('effectiveRequestParams changed', {
from: oldVal?.substring(0, 80),
to: newVal?.substring(0, 80),
@@ -217,6 +220,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
async function refreshSearch() {
const version = ++searchVersion
refreshing.value = true
debug('refreshSearch start', {
version,
projectType: options.projectType.value,
@@ -253,11 +257,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
updateUrlParams()
loading.value = false
refreshing.value = false
} catch (err) {
debug('refreshSearch error', err)
console.error('Browse search error:', err)
if (version === searchVersion) {
loading.value = false
refreshing.value = false
}
}
}
@@ -323,6 +329,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
effectiveSortTypes,
effectiveCurrentSortType,
loading,
refreshing,
projectHits,
serverHits,
totalHits,
@@ -31,6 +31,7 @@ export interface BrowseManagerContext {
effectiveSortTypes: ComputedRef<readonly SortType[]>
effectiveCurrentSortType: Ref<SortType>
loading: Ref<boolean>
refreshing: Ref<boolean>
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
totalHits: Ref<number>
@@ -194,6 +194,8 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-model:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-filters="ctx.providedFilters?.value ?? []"
:filter-type="filter"
:result-count="ctx.totalHits.value"
:refreshing="ctx.refreshing.value"
:class="filterClass"
:button-class="buttonClass"
:content-class="contentClass"
+12
View File
@@ -3548,6 +3548,15 @@
"s.bg": {
"defaultMessage": "Background task running"
},
"search.filter.clear": {
"defaultMessage": "Clear"
},
"search.filter.dependent_count": {
"defaultMessage": "{count, plural, one {# dependent} other {# dependents}}"
},
"search.filter.dependent_project": {
"defaultMessage": "Depends on: {project}"
},
"search.filter.locked.default": {
"defaultMessage": "Filter locked"
},
@@ -3590,6 +3599,9 @@
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Exclude plugins"
},
"search.filter_type.compatible_dependency_project_ids": {
"defaultMessage": "Depends on"
},
"search.filter_type.environment": {
"defaultMessage": "Environment"
},
+25 -4
View File
@@ -43,10 +43,11 @@ export type FilterType = {
}[]
searchable: boolean
allows_custom_options?: 'and' | 'or'
custom_option_field?: string
ordering?: number
} & (
| {
display: 'all' | 'scrollable' | 'none' | 'toggle'
display: 'all' | 'scrollable' | 'none' | 'project' | 'toggle'
}
| {
display: 'expandable'
@@ -472,6 +473,24 @@ export function useSearch(
options: [],
allows_custom_options: 'and',
},
{
id: 'compatible_dependency_project_ids',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.compatible_dependency_project_ids',
defaultMessage: 'Depends on',
}),
),
supported_project_types: ALL_PROJECT_TYPES,
query_param: 'dep',
supports_negative_filter: false,
display: 'project',
searchable: false,
options: [],
allows_custom_options: 'and',
custom_option_field: 'compatible_dependency_project_ids',
ordering: -999,
},
{
id: 'advanced',
formatted_name: formatMessage(
@@ -533,7 +552,9 @@ export function useSearch(
formatted_name: filterValue.option,
icon: undefined,
method: type.allows_custom_options,
value: filterValue.option,
value: type.custom_option_field
? `${type.custom_option_field}:${filterValue.option}`
: filterValue.option,
}
} else if (!option) {
console.error(`Filter option ${filterValue.option} not found`)
@@ -771,8 +792,8 @@ export function useSearch(
currentFilters.value.forEach((filterValue) => {
const type = filters.value.find((type) => type.id === filterValue.type)
const option = type?.options.find((option) => option.id === filterValue.option)
if (type && option) {
const value = getOptionValue(option, filterValue.negative)
if (type && (option || type.allows_custom_options)) {
const value = option ? getOptionValue(option, filterValue.negative) : filterValue.option
if (items[type.query_param]) {
items[type.query_param].push(value)
} else {