feat: depends on search filter (#6713)

* feat: add norisk client source support (#6707)

Signed-off-by: Robin <me@orb1n.dev>

* changelog

* feat: implement depends on search filter

* feat: add modpack "includes content" filter and update depends on

* feat: style pass

* feat: show included modpack projects filter tag better

* pnpm prepr

* feat: polish depends on filter

* fix: clear button, page scroll, and metadata loading state

* feat: hook up excludes modpack

* fix: combobox closing race

* Revert "fix: combobox closing race"

This reverts commit e51529ca65.

* fix scrolling after applying depends on

* feedback

* pnpm prepr

* slightly make buttons larger for touch devices

* feat: add view dependents/view modpacks in project overflow menu

* pnpm prepr

* fix: divider

* add icon

* fix: project search combobox background in app

---------

Signed-off-by: Robin <me@orb1n.dev>
Co-authored-by: Robin <robin.murer11@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Truman Gao
2026-08-20 19:01:17 +00:00
committed by GitHub
co-authored by Robin Prospector
parent 5d54cfa0dc
commit 5181370293
17 changed files with 991 additions and 110 deletions
+2
View File
@@ -213,6 +213,7 @@ import _PackageIcon from './icons/package.svg?component'
import _PackageClosedIcon from './icons/package-closed.svg?component'
import _PackageOpenIcon from './icons/package-open.svg?component'
import _PackagePlusIcon from './icons/package-plus.svg?component'
import _PackageSearchIcon from './icons/package-search.svg?component'
import _PageRoundIcon from './icons/page-round.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
@@ -656,6 +657,7 @@ export const PackageIcon = _PackageIcon
export const PackageClosedIcon = _PackageClosedIcon
export const PackageOpenIcon = _PackageOpenIcon
export const PackagePlusIcon = _PackagePlusIcon
export const PackageSearchIcon = _PackageSearchIcon
export const PageRoundIcon = _PageRoundIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-package-search"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14" />
<path d="m7.5 4.27 9 5.15" />
<polyline points="3.29 7 12 12 20.71 7" />
<line x1="12" x2="12" y1="22" y2="12" />
<circle cx="18.5" cy="15.5" r="2.5" />
<path d="M20.27 17.27 22 19" />
</svg>

After

Width:  |  Height:  |  Size: 612 B

+23 -5
View File
@@ -1,10 +1,14 @@
<template>
<div ref="containerRef" class="relative inline-block w-full">
<!-- Searchable mode: input trigger -->
<div v-if="searchable" class="relative w-full rounded-xl bg-surface-4">
<div
v-if="searchable"
class="relative w-full rounded-xl"
:class="{ 'bg-surface-4': searchInputVariant === 'surface' }"
>
<!--
Selection mirror: horizontal padding must match StyledInput (filled + left icon uses `pl-10`,
else `pl-3`) and `searchableInputClass` when the chevron is shown (`!pr-9`), or the overlay
else `pl-3`) and `resolvedSearchInputClass` when the chevron is shown (`!pr-9`), or the overlay
text will not line up with the transparent input text / caret.
-->
<div
@@ -24,6 +28,7 @@
:name="searchName"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
:clearable="clearable"
:autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize"
@@ -31,13 +36,14 @@
:inputmode="searchInputmode"
:input-attrs="searchInputAttrs"
wrapper-class="w-full !bg-transparent"
:input-class="searchableInputClass"
:input-class="resolvedSearchInputClass"
class="relative z-[1]"
@input="handleSearchInput"
@keydown="handleSearchKeydown"
@focusin="handleSearchFocus"
@focusout="handleSearchFocusout"
@click="handleSearchClick"
@clear="handleSearchClear"
>
<template v-if="showChevron" #right>
<ChevronLeftIcon
@@ -234,6 +240,8 @@ export interface ComboboxOption<T> {
searchTerms?: string[]
}
export type ComboboxSearchInputVariant = 'surface' | 'button'
type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>>
type ViewportRect = {
width: number
@@ -274,6 +282,7 @@ const props = withDefaults(
placeholder?: string
disabled?: boolean
searchable?: boolean
clearable?: boolean
searchPlaceholder?: string
listbox?: boolean
showChevron?: boolean
@@ -311,11 +320,13 @@ const props = withDefaults(
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean
searchInputAttrs?: Record<string, string | number | boolean | undefined>
searchInputVariant?: ComboboxSearchInputVariant
}>(),
{
placeholder: 'Select an option',
disabled: false,
searchable: false,
clearable: false,
searchPlaceholder: 'Search...',
listbox: true,
showChevron: true,
@@ -327,6 +338,7 @@ const props = withDefaults(
selectSearchTextOnFocus: false,
showSearchIcon: false,
searchType: 'text',
searchInputVariant: 'surface',
triggerType: 'base',
triggerSize: 'md',
triggerInteraction: 'surface',
@@ -397,8 +409,8 @@ const searchSelectionOverlayVisible = computed(() => {
return true
})
const searchableInputClass = computed(() => {
const parts = ['!bg-transparent']
const resolvedSearchInputClass = computed(() => {
const parts = [props.searchInputVariant === 'button' ? '!bg-button-bg' : '!bg-transparent']
if (props.showChevron) parts.push('!pr-9')
if (searchSelectionOverlayVisible.value) {
parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent')
@@ -841,6 +853,12 @@ function handleSearchInput() {
}
}
function handleSearchClear() {
userHasTyped.value = true
emit('searchInput', searchQuery.value)
closeDropdown()
}
function handleSearchFocus(event: FocusEvent) {
const target = event.target
if (props.selectSearchTextOnFocus && target instanceof HTMLInputElement) {
+1 -1
View File
@@ -38,7 +38,7 @@ export { default as Collapsible } from './Collapsible.vue'
export type { CollapsibleAdmonitionItem } from './CollapsibleAdmonition.vue'
export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export type { ComboboxOption } from './Combobox.vue'
export type { ComboboxOption, ComboboxSearchInputVariant } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DatePicker } from './DatePicker.vue'
@@ -8,6 +8,10 @@
:no-options-message="searchLoading ? loadingMessage : noResultsMessage"
:disable-search-filter="true"
:disabled="disabled"
:clearable="clearable"
:search-input-variant="searchInputVariant"
:sync-with-selection="syncWithSelection"
:select-search-text-on-focus="selectSearchTextOnFocus"
show-icon-in-selected
@search-input="(query) => handleSearch(query)"
/>
@@ -20,7 +24,7 @@ import Fuse from 'fuse.js'
import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue'
import { injectModrinthClient, injectNotificationManager } from '../../providers'
import type { ComboboxOption } from '../base/Combobox.vue'
import type { ComboboxOption, ComboboxSearchInputVariant } from '../base/Combobox.vue'
import Combobox from '../base/Combobox.vue'
export type ProjectType =
@@ -32,12 +36,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(
@@ -54,6 +59,14 @@ const props = withDefaults(
noResultsMessage?: string
/** Whether the combobox is disabled */
disabled?: boolean
/** Whether to show a button for clearing the search input */
clearable?: boolean
/** Visual treatment for the search input */
searchInputVariant?: ComboboxSearchInputVariant
/** Keep the selected project's label in the search input */
syncWithSelection?: boolean
/** Select all search text when the input receives focus */
selectSearchTextOnFocus?: boolean
/** Maximum number of results to show */
limit?: number
/** Project IDs to exclude from results */
@@ -69,6 +82,8 @@ const props = withDefaults(
loadingMessage: 'Loading...',
noResultsMessage: 'No results found',
disabled: false,
searchInputVariant: 'surface',
syncWithSelection: true,
limit: 20,
},
)
@@ -83,6 +98,10 @@ const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
const { labrinth } = injectModrinthClient()
function isAllowedProjectType(projectType: string): boolean {
return !props.projectTypes || props.projectTypes.includes(projectType as ProjectType)
}
const userProjectHits = ref<SearchHit[]>([])
const userProjectsFuse = ref<Fuse<SearchHit> | null>(null)
@@ -163,7 +182,7 @@ watch(
} else {
try {
const project = await labrinth.projects_v2.get(newId)
if (project) {
if (project && isAllowedProjectType(project.project_type)) {
hit = {
project_id: project.id,
title: project.title,
@@ -178,6 +197,9 @@ watch(
return
}
}
if (hit && !isAllowedProjectType(hit.project_type)) {
hit = null
}
selectedProject.value = hit
if (hit && !options.value.some((o) => o.value === hit!.project_id)) {
@@ -220,7 +242,11 @@ const search = async (query: string) => {
const uniqueHits: SearchHit[] = []
for (const hit of allHits) {
if (!seenIds.has(hit.project_id) && !excludeSet.has(hit.project_id)) {
if (
isAllowedProjectType(hit.project_type) &&
!seenIds.has(hit.project_id) &&
!excludeSet.has(hit.project_id)
) {
seenIds.add(hit.project_id)
uniqueHits.push(hit)
searchResultsCache.value.set(hit.project_id, hit)
@@ -0,0 +1,401 @@
<template>
<div v-if="isModpack" :class="innerPanelClass" class="flex flex-col gap-3">
<ProjectCombobox
:model-value="pendingProjectId"
:project-types="selectableProjectTypes"
:exclude-project-ids="dependencyProjectIds"
:search-placeholder="formatMessage(messages.searchContentPlaceholder)"
:show-chevron="false"
:sync-with-selection="false"
clearable
search-input-variant="button"
show-search-icon
@update:model-value="addIncludedProject"
/>
<div v-if="loadedDependentProjects.length > 0" class="flex flex-col gap-1">
<div
v-for="{ projectId, project } in loadedDependentProjects"
:key="projectId"
class="group flex min-w-0 items-center gap-2 rounded-xl px-1 pr-0 py-1 text-primary"
>
<img
v-if="project.icon_url"
:src="project.icon_url"
:alt="project.title"
class="size-6 shrink-0 rounded-md object-cover"
/>
<PackageIcon v-else class="size-8 shrink-0 text-secondary" />
<span class="min-w-0 flex-1 truncate font-medium text-contrast">
{{ project.title }}
</span>
<div class="flex items-center gap-0.5">
<button
v-tooltip="
excludedProjectIds.has(projectId)
? formatMessage(messages.excluded)
: formatMessage(messages.exclude)
"
type="button"
class="flex shrink-0 cursor-pointer items-center justify-center rounded-xl border-0 px-2 py-2 transition-all [@media(hover:hover)]:py-1"
:class="
excludedProjectIds.has(projectId)
? 'text-red opacity-100 bg-highlight-red'
: 'text-secondary [@media(hover:hover)]:opacity-0 group-hover:opacity-100 bg-transparent hover:bg-button-bg hover:text-red active:scale-[0.96]'
"
:aria-label="
excludedProjectIds.has(projectId)
? formatMessage(messages.excluded)
: formatMessage(messages.exclude)
"
@click="toggleProjectExcluded(projectId)"
>
<BanIcon class="size-4" aria-hidden="true" />
</button>
<button
v-tooltip="formatMessage(messages.removeIncludedProjectTooltip)"
type="button"
class="flex shrink-0 cursor-pointer items-center justify-center rounded-xl border-0 bg-transparent px-2 py-2 text-secondary transition-all [@media(hover:hover)]:py-1 [@media(hover:hover)]:opacity-0 group-hover:opacity-100 hover:bg-button-bg hover:text-contrast active:scale-[0.96]"
:aria-label="
formatMessage(messages.removeIncludedProject, {
project: project.title,
})
"
@click="removeIncludedProject(projectId)"
>
<XIcon class="size-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div v-else :class="innerPanelClass" class="flex flex-col gap-3">
<ProjectCombobox
v-show="!showSelectedProject"
ref="projectCombobox"
:model-value="selectedProjectId"
:project-types="selectableProjectTypes"
:search-placeholder="formatMessage(messages.searchProjectPlaceholder)"
:show-chevron="false"
clearable
search-input-variant="button"
select-search-text-on-focus
show-search-icon
@update:model-value="setSelectedProjectId"
/>
<template v-if="showSelectedProject">
<div
class="flex items-center gap-2 rounded-2xl p-2.5"
:class="selectedProjectClass ?? 'bg-surface-2'"
>
<img
v-if="selectedProject?.icon_url"
:src="selectedProject.icon_url"
:alt="selectedProject.title"
class="size-12 shrink-0 rounded-xl object-cover"
/>
<PackageIcon v-else class="size-14 shrink-0 text-secondary" />
<div class="min-w-0 flex-1">
<div class="truncate text-base font-bold text-contrast">
{{ selectedProject?.title ?? selectedProjectId }}
</div>
<div class="relative right-2.5">
<MultiSelect
:model-value="draftDependencyTypes"
:options="dependencyTypeOptions"
:clearable="false"
fit-content
show-chevron
trigger-class="!rounded-none !bg-transparent !p-0"
checkbox-position="right"
:dropdown-min-width="220"
@open="resetDraftDependencyTypes"
@close="commitDependencyTypes"
@update:model-value="setDraftDependencyTypes"
>
<template #input-content="{ isOpen }">
<span class="flex items-center gap-0.5 text-sm text-secondary ml-2.5">
{{ dependencyTypeLabel }}
<DropdownIcon
class="size-4 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</span>
</template>
</MultiSelect>
</div>
</div>
<IconButton
v-tooltip="formatMessage(messages.clearDependencyFilter)"
type="quiet"
size="sm"
class="ml-auto"
:label="formatMessage(messages.clearDependencyFilter)"
@click="setSelectedProjectId(undefined)"
>
<XIcon aria-hidden="true" />
</IconButton>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { BanIcon, DropdownIcon, PackageIcon, XIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers'
import {
type DependencyType,
type FilterValue,
formatDependencyProjectFilterOption,
parseDependencyProjectFilterOption,
} from '../../utils/search'
import { IconButton } from '../base/buttons'
import MultiSelect, { type MultiSelectOption } from '../base/MultiSelect.vue'
import ProjectCombobox, {
type ProjectType as ProjectComboboxProjectType,
type SearchHit,
} from '../project/ProjectCombobox.vue'
const FILTER_TYPE_ID = 'compatible_dependency_project_ids'
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const props = defineProps<{
projectType: string
innerPanelClass?: string
selectedProjectClass?: string
resultCount?: number
refreshing?: boolean
}>()
const selectableProjectTypes: ProjectComboboxProjectType[] = [
'mod',
'resourcepack',
'shader',
'datapack',
'plugin',
]
const isModpack = computed(() => props.projectType === 'modpack')
const dependencyProjectIds = computed(() => [
...new Set(
selectedFilters.value
.filter((filter) => filter.type === FILTER_TYPE_ID)
.map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
),
])
const pendingProjectId = ref<string>()
const excludedProjectIds = computed(
() =>
new Set(
selectedFilters.value
.filter((filter) => filter.type === FILTER_TYPE_ID && filter.negative)
.map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
),
)
const { data: dependentProjects } = useQuery({
queryKey: computed(() => [
'search-depends-on-filter',
'dependent-projects',
dependencyProjectIds.value,
]),
queryFn: () => labrinth.projects_v2.getMultiple(dependencyProjectIds.value),
enabled: computed(() => isModpack.value && dependencyProjectIds.value.length > 0),
placeholderData: (previousData) => previousData,
refetchOnWindowFocus: false,
})
const dependentProjectMap = computed(
() => new Map(dependentProjects.value?.map((project) => [project.id, project]) ?? []),
)
const loadedDependentProjects = computed(() =>
dependencyProjectIds.value.flatMap((projectId) => {
const project = dependentProjectMap.value.get(projectId)
return project ? [{ projectId, project }] : []
}),
)
const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null)
const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null)
const selectedDependencyFilter = computed(() =>
selectedFilters.value.find((filter) => filter.type === FILTER_TYPE_ID),
)
const selectedProjectId = computed(() =>
selectedDependencyFilter.value
? parseDependencyProjectFilterOption(selectedDependencyFilter.value.option).projectId
: undefined,
)
const showSelectedProject = computed(() =>
Boolean(selectedProjectId.value && selectedProject.value),
)
const dependencyTypes = computed<DependencyType[]>(() =>
selectedDependencyFilter.value
? parseDependencyProjectFilterOption(selectedDependencyFilter.value.option).dependencyTypes
: ['required'],
)
const draftDependencyTypes = ref<DependencyType[]>([...dependencyTypes.value])
watch(
dependencyTypes,
(types) => {
draftDependencyTypes.value = [...types]
},
{ immediate: true },
)
const dependencyTypeOptions = computed<MultiSelectOption<DependencyType>[]>(() => [
{ value: 'required', label: formatMessage(messages.required) },
{ value: 'optional', label: formatMessage(messages.optional) },
{ value: 'embedded', label: formatMessage(messages.embedded) },
])
const dependencyTypeLabel = computed(() => {
const selectedTypes = new Set(draftDependencyTypes.value)
if (selectedTypes.size === dependencyTypeOptions.value.length) {
return formatMessage(messages.anyDependencyType)
}
if (selectedTypes.has('required') && selectedTypes.has('optional')) {
return formatMessage(messages.requiredOrOptional)
}
if (selectedTypes.has('required') && selectedTypes.has('embedded')) {
return formatMessage(messages.requiredOrEmbedded)
}
if (selectedTypes.has('optional') && selectedTypes.has('embedded')) {
return formatMessage(messages.optionalOrEmbedded)
}
return (
dependencyTypeOptions.value.find((option) => selectedTypes.has(option.value))?.label ??
'Select type'
)
})
function setSelectedProjectId(projectId: string | undefined) {
const otherFilters = selectedFilters.value.filter((filter) => filter.type !== FILTER_TYPE_ID)
selectedFilters.value = projectId
? [
...otherFilters,
{
type: FILTER_TYPE_ID,
option: formatDependencyProjectFilterOption(projectId, ['required']),
},
]
: otherFilters
}
function addIncludedProject(projectId: string | undefined) {
if (projectId && !dependencyProjectIds.value.includes(projectId)) {
selectedFilters.value = [...selectedFilters.value, { type: FILTER_TYPE_ID, option: projectId }]
}
pendingProjectId.value = undefined
}
function removeIncludedProject(projectId: string) {
selectedFilters.value = selectedFilters.value.filter(
(filter) =>
filter.type !== FILTER_TYPE_ID ||
parseDependencyProjectFilterOption(filter.option).projectId !== projectId,
)
}
function toggleProjectExcluded(projectId: string) {
selectedFilters.value = selectedFilters.value.map((filter) =>
filter.type === FILTER_TYPE_ID &&
parseDependencyProjectFilterOption(filter.option).projectId === projectId
? { ...filter, negative: !filter.negative }
: filter,
)
}
function resetDraftDependencyTypes() {
draftDependencyTypes.value = [...dependencyTypes.value]
}
function setDraftDependencyTypes(types: DependencyType[]) {
draftDependencyTypes.value = types
}
function commitDependencyTypes() {
const projectId = selectedProjectId.value
const types: DependencyType[] =
draftDependencyTypes.value.length > 0 ? draftDependencyTypes.value : ['required']
draftDependencyTypes.value = types
const selectedTypes = new Set(dependencyTypes.value)
const changed =
types.length !== selectedTypes.size || types.some((type) => !selectedTypes.has(type))
if (changed && projectId) {
selectedFilters.value = selectedFilters.value.map((filter) =>
filter.type === FILTER_TYPE_ID
? {
...filter,
option: formatDependencyProjectFilterOption(projectId, types),
}
: filter,
)
}
}
const messages = defineMessages({
clearDependencyFilter: {
id: 'search.filter.dependent_project.clear',
defaultMessage: 'Clear dependency filter',
},
searchContentPlaceholder: {
id: 'search.filter.included_content.search_placeholder',
defaultMessage: 'Search content...',
},
searchProjectPlaceholder: {
id: 'search.filter.dependent_project.search_placeholder',
defaultMessage: 'Search for a project...',
},
exclude: {
id: 'search.filter.included_content.exclude',
defaultMessage: 'Exclude',
},
excluded: {
id: 'search.filter.included_content.excluded',
defaultMessage: 'Excluded',
},
removeIncludedProject: {
id: 'search.filter.included_content.remove_project',
defaultMessage: 'Remove {project}',
},
removeIncludedProjectTooltip: {
id: 'search.filter.included_content.remove_project.tooltip',
defaultMessage: 'Remove project',
},
required: {
id: 'search.filter.dependency_type.required',
defaultMessage: 'Required',
},
optional: {
id: 'search.filter.dependency_type.optional',
defaultMessage: 'Optional',
},
embedded: {
id: 'search.filter.dependency_type.embedded',
defaultMessage: 'Embedded',
},
anyDependencyType: {
id: 'search.filter.dependency_type.any',
defaultMessage: 'Any dependency type',
},
requiredOrOptional: {
id: 'search.filter.dependency_type.required_or_optional',
defaultMessage: 'Required or optional',
},
requiredOrEmbedded: {
id: 'search.filter.dependency_type.required_or_embedded',
defaultMessage: 'Required or embedded',
},
optionalOrEmbedded: {
id: 'search.filter.dependency_type.optional_or_embedded',
defaultMessage: 'Optional or embedded',
},
})
</script>
@@ -1,15 +1,32 @@
<template>
<div class="flex flex-wrap items-center gap-1 empty:hidden">
<TagItem
v-if="selectedItems.length > 1"
v-if="selectedTagCount > 1"
class="transition-transform active:scale-[0.95]"
:action="clearFilters"
>
<XCircleIcon />
Clear all filters
</TagItem>
<TagItem v-if="selectedIncludedProjectItems.length > 0" :action="removeIncludedProjectFilters">
<XIcon />
{{
formatMessage(includedProjectsMessage, {
projects: formatProjectNames(selectedIncludedProjectItems),
})
}}
</TagItem>
<TagItem v-if="selectedExcludedProjectItems.length > 0" :action="removeExcludedProjectFilters">
<XIcon />
<BanIcon class="text-brand-red" />
{{
formatMessage(excludedProjectsMessage, {
projects: formatProjectNames(selectedExcludedProjectItems),
})
}}
</TagItem>
<TagItem
v-for="selectedItem in selectedItems"
v-for="selectedItem in otherSelectedItems"
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
:action="() => removeFilter(selectedItem)"
>
@@ -37,23 +54,28 @@
<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,
type FilterType,
type FilterValue,
flattenFilterOptions,
parseDependencyProjectFilterOption,
} from '../../utils/search'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const props = defineProps<{
filters: FilterType[]
projectType: string
providedFilters: FilterValue[]
overriddenProvidedFilterTypes: string[]
providedMessage?: MessageDescriptor | string
@@ -63,6 +85,18 @@ const defaultProvidedMessage = defineMessage({
id: 'search.filter.locked.default',
defaultMessage: 'Filter locked',
})
const dependentProjectMessage = defineMessage({
id: 'search.filter.dependent_project',
defaultMessage: 'Depends on: {project}',
})
const includedProjectsMessage = defineMessage({
id: 'search.filter.included_projects',
defaultMessage: 'Includes: {projects}',
})
const excludedProjectsMessage = defineMessage({
id: 'search.filter.excluded_projects',
defaultMessage: 'Excludes: {projects}',
})
type Item = {
type: string
@@ -72,13 +106,39 @@ type Item = {
provided: boolean
}
const dependentProjectIds = computed(() =>
[
...new Set(
[...selectedFilters.value, ...props.providedFilters]
.filter((filter) => filter.type === 'compatible_dependency_project_ids')
.map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
),
].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) =>
flattenFilterOptions(type.options)
return props.filters.flatMap((type) => {
const optionItems = flattenFilterOptions(type.options)
.filter(
(option) =>
filterMatches(type, option, selectedFilters.value) ||
@@ -91,11 +151,70 @@ 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) => {
const projectId = parseDependencyProjectFilterOption(filter.option).projectId
return {
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(projectId) ?? projectId,
}),
}
}),
]
})
})
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
const selectedIncludedProjectItems = computed(() =>
props.projectType === 'modpack'
? selectedItems.value.filter(
(item) => item.type === 'compatible_dependency_project_ids' && !item.negative,
)
: [],
)
const selectedExcludedProjectItems = computed(() =>
props.projectType === 'modpack'
? selectedItems.value.filter(
(item) => item.type === 'compatible_dependency_project_ids' && item.negative,
)
: [],
)
const otherSelectedItems = computed(() =>
props.projectType === 'modpack'
? selectedItems.value.filter((item) => item.type !== 'compatible_dependency_project_ids')
: selectedItems.value,
)
const selectedTagCount = computed(
() =>
otherSelectedItems.value.length +
(selectedIncludedProjectItems.value.length > 0 ? 1 : 0) +
(selectedExcludedProjectItems.value.length > 0 ? 1 : 0),
)
function formatProjectNames(items: Item[]) {
return items
.map((item) => {
const projectId = parseDependencyProjectFilterOption(item.option).projectId
return dependentProjectNames.value.get(projectId) ?? projectId
})
.join(', ')
}
function removeFilter(filter: Item) {
selectedFilters.value = selectedFilters.value.filter(
@@ -103,6 +222,18 @@ function removeFilter(filter: Item) {
)
}
function removeIncludedProjectFilters() {
selectedFilters.value = selectedFilters.value.filter(
(filter) => filter.type !== 'compatible_dependency_project_ids' || filter.negative,
)
}
function removeExcludedProjectFilters() {
selectedFilters.value = selectedFilters.value.filter(
(filter) => filter.type !== 'compatible_dependency_project_ids' || !filter.negative,
)
}
async function clearFilters() {
selectedFilters.value = []
}
@@ -69,107 +69,120 @@
</template>
<template v-else #default>
<slot name="prefix" />
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
<SearchDependsOnFilter
v-if="filterType.display === 'depends-on-project'"
v-model:selected-filters="selectedFilters"
:project-type="projectType"
:inner-panel-class="innerPanelClass"
:selected-project-class="selectedProjectClass"
:result-count="resultCount"
:loading="loading"
:refreshing="refreshing"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports="filterType.supports"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`">
<SearchFilterOption
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
<template v-if="filterType.display !== 'depends-on-project'">
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports="filterType.supports"
:has-sub-options="!!option.sub_options?.length"
:expanded="isExpanded(option)"
:class="{
'mr-3': scrollable,
}"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
@toggle-expand="toggleExpand(option)"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
<div
v-if="option.sub_options?.length && isExpanded(option)"
class="ml-4 flex flex-col gap-1"
:class="{ 'mr-3': scrollable }"
>
/>
</template>
<template v-else>
<template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`">
<SearchFilterOption
v-for="subOption in option.sub_options"
:key="`${filterType.id}-${subOption.id}`"
:option="subOption"
:included="isIncluded(subOption)"
:excluded="isExcluded(subOption)"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports="filterType.supports"
:has-sub-options="!!option.sub_options?.length"
:expanded="isExpanded(option)"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
@toggle-expand="toggleExpand(option)"
>
<slot name="option" :filter="filterType" :option="subOption">
<span class="truncate text-sm">
{{ subOption.formatted_name ?? subOption.id }}
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</div>
<div
v-if="option.sub_options?.length && isExpanded(option)"
class="ml-4 flex flex-col gap-1"
:class="{ 'mr-3': scrollable }"
>
<SearchFilterOption
v-for="subOption in option.sub_options"
:key="`${filterType.id}-${subOption.id}`"
:option="subOption"
:included="isIncluded(subOption)"
:excluded="isExcluded(subOption)"
:supports="filterType.supports"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="subOption">
<span class="truncate text-sm">
{{ subOption.formatted_name ?? subOption.id }}
</span>
</slot>
</SearchFilterOption>
</div>
</template>
</template>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button>
</div>
</ScrollablePanel>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{
showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
}}
</span>
</button>
</div>
</ScrollablePanel>
</template>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
@@ -219,6 +232,7 @@ import {
} from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchDependsOnFilter from './SearchDependsOnFilter.vue'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
@@ -233,11 +247,16 @@ const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedF
const props = defineProps<{
filterType: FilterType
projectType: string
buttonClass?: string
contentClass?: string
innerPanelClass?: string
selectedProjectClass?: string
openByDefault?: boolean
providedFilters: FilterValue[]
resultCount?: number
loading?: boolean
refreshing?: boolean
}>()
defineOptions({
@@ -58,6 +58,7 @@ export interface BrowseSearchState {
effectiveCurrentSortType: Ref<SortType>
loading: Ref<boolean>
refreshing: Ref<boolean>
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
totalHits: Ref<number>
@@ -186,6 +187,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)
@@ -297,6 +299,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),
@@ -323,6 +326,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
}
const version = ++searchVersion
refreshing.value = true
debug('refreshSearch start', {
version,
projectType: options.projectType.value,
@@ -363,11 +367,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
}
}
}
@@ -441,6 +447,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
effectiveSortTypes,
effectiveCurrentSortType,
loading,
refreshing,
projectHits,
serverHits,
totalHits,
@@ -237,6 +237,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
v-if="ctx.isServerType.value"
v-model:selected-filters="ctx.serverCurrentFilters.value"
:filters="ctx.serverFilterTypes.value"
:project-type="ctx.projectType.value"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
@@ -248,12 +249,13 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
(f) => f.display !== 'none' && !(ctx.hiddenFilterTypes?.value ?? []).includes(f.id),
)
"
:project-type="ctx.projectType.value"
:provided-filters="ctx.providedFilters?.value ?? []"
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-message="lockedMessages?.providedBy"
/>
<div class="search">
<div class="search [overflow-anchor:none]">
<section v-if="ctx.loading.value" class="offline">
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
</section>
@@ -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>
@@ -92,6 +92,9 @@ const buttonClass = computed(() => {
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
const innerPanelClass = computed(() => (isApp.value ? 'ml-2 mr-3' : 'p-1'))
const selectedProjectClass = computed(() =>
isApp.value ? 'bg-surface-1 border border-solid border-surface-3' : 'bg-surface-2',
)
function hasProvidedFilter(filterId: string): boolean {
return (ctx.providedFilters?.value ?? []).some((filter) => filter.type === filterId)
@@ -104,6 +107,9 @@ function getFilterOpenByDefault(filterId: string): boolean {
if (hasProvidedFilter(filterId)) {
return true
}
if (filterId === 'compatible_dependency_project_ids') {
return true
}
if (ctx.isServerType.value) {
return ![
'server_category_minecraft_server_meta',
@@ -214,10 +220,12 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-model:toggled-groups="ctx.serverToggledGroups.value"
:provided-filters="[]"
:filter-type="filterType"
:project-type="ctx.projectType.value"
:class="filterClass"
:button-class="buttonClass"
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:selected-project-class="selectedProjectClass"
:open-by-default="getFilterOpenByDefault(filterType.id)"
@on-open="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
@@ -243,10 +251,15 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-model:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-filters="ctx.providedFilters?.value ?? []"
:filter-type="filter"
:project-type="ctx.projectType.value"
:result-count="ctx.totalHits.value"
:loading="ctx.loading.value"
:refreshing="ctx.refreshing.value"
:class="filterClass"
:button-class="buttonClass"
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:selected-project-class="selectedProjectClass"
:open-by-default="getFilterOpenByDefault(filter.id)"
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
+57
View File
@@ -4106,6 +4106,57 @@
"s.bg": {
"defaultMessage": "Background task running"
},
"search.filter.dependency_type.any": {
"defaultMessage": "Any dependency type"
},
"search.filter.dependency_type.embedded": {
"defaultMessage": "Embedded"
},
"search.filter.dependency_type.optional": {
"defaultMessage": "Optional"
},
"search.filter.dependency_type.optional_or_embedded": {
"defaultMessage": "Optional or embedded"
},
"search.filter.dependency_type.required": {
"defaultMessage": "Required"
},
"search.filter.dependency_type.required_or_embedded": {
"defaultMessage": "Required or embedded"
},
"search.filter.dependency_type.required_or_optional": {
"defaultMessage": "Required or optional"
},
"search.filter.dependent_project": {
"defaultMessage": "Depends on: {project}"
},
"search.filter.dependent_project.clear": {
"defaultMessage": "Clear dependency filter"
},
"search.filter.dependent_project.search_placeholder": {
"defaultMessage": "Search for a project..."
},
"search.filter.excluded_projects": {
"defaultMessage": "Excludes: {projects}"
},
"search.filter.included_content.exclude": {
"defaultMessage": "Exclude"
},
"search.filter.included_content.excluded": {
"defaultMessage": "Excluded"
},
"search.filter.included_content.remove_project": {
"defaultMessage": "Remove {project}"
},
"search.filter.included_content.remove_project.tooltip": {
"defaultMessage": "Remove project"
},
"search.filter.included_content.search_placeholder": {
"defaultMessage": "Search content..."
},
"search.filter.included_projects": {
"defaultMessage": "Includes: {projects}"
},
"search.filter.locked.default": {
"defaultMessage": "Filter locked"
},
@@ -4172,6 +4223,9 @@
"search.filter_type.advanced.disclosure.telemetry": {
"defaultMessage": "Telemetry"
},
"search.filter_type.compatible_dependency_project_ids": {
"defaultMessage": "Depends on"
},
"search.filter_type.environment": {
"defaultMessage": "Environment"
},
@@ -4187,6 +4241,9 @@
"search.filter_type.game_version.all_versions": {
"defaultMessage": "Show all versions"
},
"search.filter_type.included_content": {
"defaultMessage": "Included content"
},
"search.filter_type.license": {
"defaultMessage": "License"
},
@@ -58,6 +58,19 @@ export const Searchable: Story = {
},
}
export const SearchableButtonVariant: Story = {
args: {
options: [
{ value: '1', label: 'Minecraft' },
{ value: '2', label: 'Fabric' },
{ value: '3', label: 'Forge' },
],
searchable: true,
searchInputVariant: 'button',
searchPlaceholder: 'Search projects...',
},
}
export const SearchableEmpty: Story = {
args: {
options: [],
+89 -4
View File
@@ -105,10 +105,11 @@ export type FilterType = {
}[]
searchable: boolean
allows_custom_options?: 'and' | 'or'
custom_option_field?: string
ordering?: number
} & (
| {
display: 'all' | 'scrollable' | 'none'
display: 'all' | 'scrollable' | 'none' | 'depends-on-project'
}
| {
display: 'expandable'
@@ -122,6 +123,45 @@ export type FilterValue = {
negative?: boolean
}
export type DependencyType = 'required' | 'optional' | 'embedded'
const DEPENDENCY_TYPE_FIELDS: Record<DependencyType, string> = {
required: 'required_dependency_project_ids',
optional: 'optional_dependency_project_ids',
embedded: 'embedded_dependency_project_ids',
}
const DEPENDENCY_TYPES = Object.keys(DEPENDENCY_TYPE_FIELDS) as DependencyType[]
export function formatDependencyProjectFilterOption(
projectId: string,
dependencyTypes: readonly DependencyType[],
): string {
return `${dependencyTypes.join(',')}:${projectId}`
}
export function parseDependencyProjectFilterOption(option: string): {
projectId: string
dependencyTypes: DependencyType[]
} {
const separatorIndex = option.indexOf(':')
if (separatorIndex === -1) {
return { projectId: option, dependencyTypes: ['required'] }
}
const dependencyTypes = option
.slice(0, separatorIndex)
.split(',')
.filter((type): type is DependencyType => DEPENDENCY_TYPES.includes(type as DependencyType))
const projectId = option.slice(separatorIndex + 1)
if (dependencyTypes.length === 0 || !projectId) {
return { projectId: option, dependencyTypes: ['required'] }
}
return { projectId, dependencyTypes }
}
export type EnvironmentSearchOverride =
| { mode: 'include'; values: string[] }
| { mode: 'exclude'; values: string[] }
@@ -353,6 +393,14 @@ export function useSearch(
const overriddenProvidedFilterTypes = ref<string[]>([])
const { formatMessage, locale } = useVIntl()
const dependsOnFilterName = defineMessage({
id: 'search.filter_type.compatible_dependency_project_ids',
defaultMessage: 'Depends on',
})
const includedContentFilterName = defineMessage({
id: 'search.filter_type.included_content',
defaultMessage: 'Included content',
})
const formatCategoryName = (categoryName: string) => {
return formatCategory(formatMessage, categoryName)
}
@@ -396,6 +444,21 @@ export function useSearch(
const filterTypes: FilterType[] = [
...Object.values(categoryFilters),
{
id: 'compatible_dependency_project_ids',
formatted_name: formatMessage(
projectTypes.value.includes('modpack') ? includedContentFilterName : dependsOnFilterName,
),
supported_project_types: ALL_PROJECT_TYPES,
query_param: 'dep',
supports: ['include'],
display: 'depends-on-project',
searchable: false,
options: [],
allows_custom_options: 'and',
custom_option_field: 'compatible_dependency_project_ids',
ordering: projectTypes.value.includes('modpack') ? undefined : -999,
},
{
id: 'environment',
formatted_name: formatMessage(
@@ -704,6 +767,26 @@ export function useSearch(
const negativeByType: Record<string, string[]> = {}
for (const filterValue of filterValues) {
if (filterValue.type === 'compatible_dependency_project_ids') {
const { projectId, dependencyTypes } = parseDependencyProjectFilterOption(
filterValue.option,
)
const fields = projectTypes.value.includes('modpack')
? ['compatible_dependency_project_ids']
: dependencyTypes.map((type) => DEPENDENCY_TYPE_FIELDS[type])
const operator = filterValue.negative ? '!=' : '='
const conditions = fields.map(
(field) => `${field} ${operator} ${formatSearchFilterValue(projectId)}`,
)
if (conditions.length === 1) {
parts.push(conditions[0])
} else {
parts.push(`(${conditions.join(filterValue.negative ? ' AND ' : ' OR ')})`)
}
continue
}
const type = filters.value.find((type) => type.id === filterValue.type)
if (!type) {
console.error(`Filter type ${filterValue.type} not found`)
@@ -719,7 +802,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`)
@@ -962,8 +1047,8 @@ export function useSearch(
currentFilters.value.forEach((filterValue) => {
const type = filters.value.find((type) => type.id === filterValue.type)
const option = type ? findFilterOption(type.options, filterValue.option) : undefined
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 {