mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
feat: implement depends on search filter
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user