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
@@ -3494,6 +3494,15 @@
"project.actions.servers-promo.title": { "project.actions.servers-promo.title": {
"message": "Create a server" "message": "Create a server"
}, },
"project.actions.view-dependents": {
"message": "View dependents"
},
"project.actions.view-modpacks": {
"message": "View modpacks"
},
"project.actions.view-project-type-dependents": {
"message": "View {projectType} dependents"
},
"project.ai-image-warning-modal.body.1": { "project.ai-image-warning-modal.body.1": {
"message": "Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended." "message": "Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended."
}, },
+78 -1
View File
@@ -564,10 +564,12 @@ import {
HeartIcon, HeartIcon,
LeftArrowIcon, LeftArrowIcon,
MoreVerticalIcon, MoreVerticalIcon,
PackageSearchIcon,
PlayIcon, PlayIcon,
ReportIcon, ReportIcon,
ScaleIcon, ScaleIcon,
ScanEyeIcon, ScanEyeIcon,
SearchIcon,
ServerPlusIcon, ServerPlusIcon,
SettingsIcon, SettingsIcon,
XIcon, XIcon,
@@ -582,6 +584,8 @@ import {
ButtonLink, ButtonLink,
commonMessages, commonMessages,
defineMessages, defineMessages,
formatDependencyProjectFilterOption,
formatProjectTypeSentence,
getActiveDisclosures, getActiveDisclosures,
IconButton, IconButton,
injectModrinthClient, injectModrinthClient,
@@ -846,6 +850,18 @@ const messages = defineMessages({
id: 'project.actions.review-project', id: 'project.actions.review-project',
defaultMessage: 'Review project', defaultMessage: 'Review project',
}, },
viewDependents: {
id: 'project.actions.view-dependents',
defaultMessage: 'View dependents',
},
viewProjectTypeDependents: {
id: 'project.actions.view-project-type-dependents',
defaultMessage: 'View {projectType} dependents',
},
viewModpacks: {
id: 'project.actions.view-modpacks',
defaultMessage: 'View modpacks',
},
rescanModpack: { rescanModpack: {
id: 'project.actions.rescan-modpack', id: 'project.actions.rescan-modpack',
defaultMessage: 'Rescan modpack', defaultMessage: 'Rescan modpack',
@@ -1904,6 +1920,30 @@ async function checkModpackArchives() {
const projectHeaderMoreActions = computed(() => { const projectHeaderMoreActions = computed(() => {
const isStaff = !!(auth.value.user && tags.value.staffRoles.includes(auth.value.user.role)) const isStaff = !!(auth.value.user && tags.value.staffRoles.includes(auth.value.user.role))
const projectId = project.value?.id
const dependentSearchTypes = getDependentSearchTypes()
const dependentSearchActions = dependentSearchTypes
.filter((projectType) => projectType !== 'modpack')
.map((projectType) => ({
id: `view-${projectType}-dependents`,
label: formatMessage(
dependentSearchTypes.length === 1
? messages.viewDependents
: messages.viewProjectTypeDependents,
{
projectType: formatProjectTypeSentence(formatMessage, projectType),
},
),
icon: SearchIcon,
type: 'link',
to: {
path: `/discover/${projectType}s`,
query: {
dep: formatDependencyProjectFilterOption(projectId, ['required']),
},
},
}))
const isPluginOnly = dependentSearchTypes.length === 1 && dependentSearchTypes[0] === 'plugin'
return [ return [
{ {
@@ -1914,7 +1954,21 @@ const projectHeaderMoreActions = computed(() => {
to: `${projectPath.value}/settings/analytics`, to: `${projectPath.value}/settings/analytics`,
shown: !!auth.value.user && !!currentMember.value, shown: !!auth.value.user && !!currentMember.value,
}, },
{ type: 'divider', shown: !!auth.value.user && !!currentMember.value }, ...dependentSearchActions,
{
id: 'view-modpacks',
label: formatMessage(messages.viewModpacks),
icon: PackageSearchIcon,
type: 'link',
to: {
path: '/discover/modpacks',
query: {
dep: formatDependencyProjectFilterOption(projectId, ['required']),
},
},
shown: !isPluginOnly && project.value?.actualProjectType !== 'modpack',
},
{ type: 'divider' },
{ {
id: 'moderation-checklist', id: 'moderation-checklist',
label: formatMessage(messages.reviewProject), label: formatMessage(messages.reviewProject),
@@ -1977,6 +2031,29 @@ const projectHeaderMoreActions = computed(() => {
] ]
}) })
function getDependentSearchTypes() {
if (!project.value) return []
if (project.value.actualProjectType !== 'mod') {
return [isServerProject.value ? 'server' : project.value.actualProjectType]
}
const loaders = project.value.loaders ?? []
const projectTypes = []
if (loaders.some((loader) => tags.value.loaderData.modLoaders.includes(loader))) {
projectTypes.push('mod')
}
if (loaders.some((loader) => tags.value.loaderData.allPluginLoaders.includes(loader))) {
projectTypes.push('plugin')
}
if (loaders.some((loader) => tags.value.loaderData.dataPackLoaders.includes(loader))) {
projectTypes.push('datapack')
}
return projectTypes.length > 0 ? projectTypes : ['mod']
}
const createCanonicalUrl = () => const createCanonicalUrl = () =>
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
+2
View File
@@ -213,6 +213,7 @@ import _PackageIcon from './icons/package.svg?component'
import _PackageClosedIcon from './icons/package-closed.svg?component' import _PackageClosedIcon from './icons/package-closed.svg?component'
import _PackageOpenIcon from './icons/package-open.svg?component' import _PackageOpenIcon from './icons/package-open.svg?component'
import _PackagePlusIcon from './icons/package-plus.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 _PageRoundIcon from './icons/page-round.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component' import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component' import _PaletteIcon from './icons/palette.svg?component'
@@ -656,6 +657,7 @@ export const PackageIcon = _PackageIcon
export const PackageClosedIcon = _PackageClosedIcon export const PackageClosedIcon = _PackageClosedIcon
export const PackageOpenIcon = _PackageOpenIcon export const PackageOpenIcon = _PackageOpenIcon
export const PackagePlusIcon = _PackagePlusIcon export const PackagePlusIcon = _PackagePlusIcon
export const PackageSearchIcon = _PackageSearchIcon
export const PageRoundIcon = _PageRoundIcon export const PageRoundIcon = _PageRoundIcon
export const PaintbrushIcon = _PaintbrushIcon export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon 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> <template>
<div ref="containerRef" class="relative inline-block w-full"> <div ref="containerRef" class="relative inline-block w-full">
<!-- Searchable mode: input trigger --> <!-- 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`, 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. text will not line up with the transparent input text / caret.
--> -->
<div <div
@@ -24,6 +28,7 @@
:name="searchName" :name="searchName"
:placeholder="searchPlaceholder || placeholder" :placeholder="searchPlaceholder || placeholder"
:disabled="disabled" :disabled="disabled"
:clearable="clearable"
:autocomplete="searchAutocomplete" :autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect" :autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize" :autocapitalize="searchAutocapitalize"
@@ -31,13 +36,14 @@
:inputmode="searchInputmode" :inputmode="searchInputmode"
:input-attrs="searchInputAttrs" :input-attrs="searchInputAttrs"
wrapper-class="w-full !bg-transparent" wrapper-class="w-full !bg-transparent"
:input-class="searchableInputClass" :input-class="resolvedSearchInputClass"
class="relative z-[1]" class="relative z-[1]"
@input="handleSearchInput" @input="handleSearchInput"
@keydown="handleSearchKeydown" @keydown="handleSearchKeydown"
@focusin="handleSearchFocus" @focusin="handleSearchFocus"
@focusout="handleSearchFocusout" @focusout="handleSearchFocusout"
@click="handleSearchClick" @click="handleSearchClick"
@clear="handleSearchClear"
> >
<template v-if="showChevron" #right> <template v-if="showChevron" #right>
<ChevronLeftIcon <ChevronLeftIcon
@@ -234,6 +240,8 @@ export interface ComboboxOption<T> {
searchTerms?: string[] searchTerms?: string[]
} }
export type ComboboxSearchInputVariant = 'surface' | 'button'
type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>> type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>>
type ViewportRect = { type ViewportRect = {
width: number width: number
@@ -274,6 +282,7 @@ const props = withDefaults(
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
searchable?: boolean searchable?: boolean
clearable?: boolean
searchPlaceholder?: string searchPlaceholder?: string
listbox?: boolean listbox?: boolean
showChevron?: boolean showChevron?: boolean
@@ -311,11 +320,13 @@ const props = withDefaults(
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters' searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean searchSpellcheck?: boolean
searchInputAttrs?: Record<string, string | number | boolean | undefined> searchInputAttrs?: Record<string, string | number | boolean | undefined>
searchInputVariant?: ComboboxSearchInputVariant
}>(), }>(),
{ {
placeholder: 'Select an option', placeholder: 'Select an option',
disabled: false, disabled: false,
searchable: false, searchable: false,
clearable: false,
searchPlaceholder: 'Search...', searchPlaceholder: 'Search...',
listbox: true, listbox: true,
showChevron: true, showChevron: true,
@@ -327,6 +338,7 @@ const props = withDefaults(
selectSearchTextOnFocus: false, selectSearchTextOnFocus: false,
showSearchIcon: false, showSearchIcon: false,
searchType: 'text', searchType: 'text',
searchInputVariant: 'surface',
triggerType: 'base', triggerType: 'base',
triggerSize: 'md', triggerSize: 'md',
triggerInteraction: 'surface', triggerInteraction: 'surface',
@@ -397,8 +409,8 @@ const searchSelectionOverlayVisible = computed(() => {
return true return true
}) })
const searchableInputClass = computed(() => { const resolvedSearchInputClass = computed(() => {
const parts = ['!bg-transparent'] const parts = [props.searchInputVariant === 'button' ? '!bg-button-bg' : '!bg-transparent']
if (props.showChevron) parts.push('!pr-9') if (props.showChevron) parts.push('!pr-9')
if (searchSelectionOverlayVisible.value) { if (searchSelectionOverlayVisible.value) {
parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent') 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) { function handleSearchFocus(event: FocusEvent) {
const target = event.target const target = event.target
if (props.selectSearchTextOnFocus && target instanceof HTMLInputElement) { 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 type { CollapsibleAdmonitionItem } from './CollapsibleAdmonition.vue'
export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue' export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.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 Combobox } from './Combobox.vue'
export { default as CopyCode } from './CopyCode.vue' export { default as CopyCode } from './CopyCode.vue'
export { default as DatePicker } from './DatePicker.vue' export { default as DatePicker } from './DatePicker.vue'
@@ -8,6 +8,10 @@
:no-options-message="searchLoading ? loadingMessage : noResultsMessage" :no-options-message="searchLoading ? loadingMessage : noResultsMessage"
:disable-search-filter="true" :disable-search-filter="true"
:disabled="disabled" :disabled="disabled"
:clearable="clearable"
:search-input-variant="searchInputVariant"
:sync-with-selection="syncWithSelection"
:select-search-text-on-focus="selectSearchTextOnFocus"
show-icon-in-selected show-icon-in-selected
@search-input="(query) => handleSearch(query)" @search-input="(query) => handleSearch(query)"
/> />
@@ -20,7 +24,7 @@ import Fuse from 'fuse.js'
import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue' import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue'
import { injectModrinthClient, injectNotificationManager } from '../../providers' 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' import Combobox from '../base/Combobox.vue'
export type ProjectType = export type ProjectType =
@@ -32,12 +36,13 @@ export type ProjectType =
| 'plugin' | 'plugin'
| 'server' | 'server'
interface SearchHit { export interface SearchHit {
project_id: string project_id: string
title: string title: string
icon_url?: string icon_url?: string
project_type: string project_type: string
slug: string slug: string
author?: string
} }
const props = withDefaults( const props = withDefaults(
@@ -54,6 +59,14 @@ const props = withDefaults(
noResultsMessage?: string noResultsMessage?: string
/** Whether the combobox is disabled */ /** Whether the combobox is disabled */
disabled?: boolean 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 */ /** Maximum number of results to show */
limit?: number limit?: number
/** Project IDs to exclude from results */ /** Project IDs to exclude from results */
@@ -69,6 +82,8 @@ const props = withDefaults(
loadingMessage: 'Loading...', loadingMessage: 'Loading...',
noResultsMessage: 'No results found', noResultsMessage: 'No results found',
disabled: false, disabled: false,
searchInputVariant: 'surface',
syncWithSelection: true,
limit: 20, limit: 20,
}, },
) )
@@ -83,6 +98,10 @@ const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
const { labrinth } = injectModrinthClient() const { labrinth } = injectModrinthClient()
function isAllowedProjectType(projectType: string): boolean {
return !props.projectTypes || props.projectTypes.includes(projectType as ProjectType)
}
const userProjectHits = ref<SearchHit[]>([]) const userProjectHits = ref<SearchHit[]>([])
const userProjectsFuse = ref<Fuse<SearchHit> | null>(null) const userProjectsFuse = ref<Fuse<SearchHit> | null>(null)
@@ -163,7 +182,7 @@ watch(
} else { } else {
try { try {
const project = await labrinth.projects_v2.get(newId) const project = await labrinth.projects_v2.get(newId)
if (project) { if (project && isAllowedProjectType(project.project_type)) {
hit = { hit = {
project_id: project.id, project_id: project.id,
title: project.title, title: project.title,
@@ -178,6 +197,9 @@ watch(
return return
} }
} }
if (hit && !isAllowedProjectType(hit.project_type)) {
hit = null
}
selectedProject.value = hit selectedProject.value = hit
if (hit && !options.value.some((o) => o.value === hit!.project_id)) { if (hit && !options.value.some((o) => o.value === hit!.project_id)) {
@@ -220,7 +242,11 @@ const search = async (query: string) => {
const uniqueHits: SearchHit[] = [] const uniqueHits: SearchHit[] = []
for (const hit of allHits) { 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) seenIds.add(hit.project_id)
uniqueHits.push(hit) uniqueHits.push(hit)
searchResultsCache.value.set(hit.project_id, 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> <template>
<div class="flex flex-wrap items-center gap-1 empty:hidden"> <div class="flex flex-wrap items-center gap-1 empty:hidden">
<TagItem <TagItem
v-if="selectedItems.length > 1" v-if="selectedTagCount > 1"
class="transition-transform active:scale-[0.95]" class="transition-transform active:scale-[0.95]"
:action="clearFilters" :action="clearFilters"
> >
<XCircleIcon /> <XCircleIcon />
Clear all filters Clear all filters
</TagItem> </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 <TagItem
v-for="selectedItem in selectedItems" v-for="selectedItem in otherSelectedItems"
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`" :key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
:action="() => removeFilter(selectedItem)" :action="() => removeFilter(selectedItem)"
> >
@@ -37,23 +54,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets' import { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import { computed, type ComputedRef } from 'vue' import { computed, type ComputedRef } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n' import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers'
import { import {
type FilterOption, type FilterOption,
type FilterType, type FilterType,
type FilterValue, type FilterValue,
flattenFilterOptions, flattenFilterOptions,
parseDependencyProjectFilterOption,
} from '../../utils/search' } from '../../utils/search'
import TagItem from '../base/TagItem.vue' import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true }) const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const props = defineProps<{ const props = defineProps<{
filters: FilterType[] filters: FilterType[]
projectType: string
providedFilters: FilterValue[] providedFilters: FilterValue[]
overriddenProvidedFilterTypes: string[] overriddenProvidedFilterTypes: string[]
providedMessage?: MessageDescriptor | string providedMessage?: MessageDescriptor | string
@@ -63,6 +85,18 @@ const defaultProvidedMessage = defineMessage({
id: 'search.filter.locked.default', id: 'search.filter.locked.default',
defaultMessage: 'Filter locked', 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 Item = {
type: string type: string
@@ -72,13 +106,39 @@ type Item = {
provided: boolean 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[]) { function filterMatches(type: FilterType, option: FilterOption, list: FilterValue[]) {
return list.some((provided) => provided.type === type.id && provided.option === option.id) return list.some((provided) => provided.type === type.id && provided.option === option.id)
} }
const items: ComputedRef<Item[]> = computed(() => { const items: ComputedRef<Item[]> = computed(() => {
return props.filters.flatMap((type) => return props.filters.flatMap((type) => {
flattenFilterOptions(type.options) const optionItems = flattenFilterOptions(type.options)
.filter( .filter(
(option) => (option) =>
filterMatches(type, option, selectedFilters.value) || filterMatches(type, option, selectedFilters.value) ||
@@ -91,11 +151,70 @@ const items: ComputedRef<Item[]> = computed(() => {
?.negative, ?.negative,
provided: filterMatches(type, option, props.providedFilters), provided: filterMatches(type, option, props.providedFilters),
formatted_name: option.formatted_name, 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 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) { function removeFilter(filter: Item) {
selectedFilters.value = selectedFilters.value.filter( 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() { async function clearFilters() {
selectedFilters.value = [] selectedFilters.value = []
} }
@@ -69,107 +69,120 @@
</template> </template>
<template v-else #default> <template v-else #default>
<slot name="prefix" /> <slot name="prefix" />
<StyledInput <SearchDependsOnFilter
v-if="filterType.searchable" v-if="filterType.display === 'depends-on-project'"
:id="`search-${filterType.id}`" v-model:selected-filters="selectedFilters"
v-model="query" :project-type="projectType"
:icon="SearchIcon" :inner-panel-class="innerPanelClass"
type="text" :selected-project-class="selectedProjectClass"
:placeholder="formatMessage(messages.searchPlaceholder)" :result-count="resultCount"
autocomplete="off" :loading="loading"
clearable :refreshing="refreshing"
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/> />
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable"> <template v-if="filterType.display !== 'depends-on-project'">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1"> <StyledInput
<template v-if="groupedOptions"> v-if="filterType.searchable"
<SearchFilterGroup :id="`search-${filterType.id}`"
v-for="[groupName, options] in groupedOptions" v-model="query"
:key="`${filterType.id}-group-${groupName}`" :icon="SearchIcon"
:group-name="groupName" type="text"
:options="options" :placeholder="formatMessage(messages.searchPlaceholder)"
:supports="filterType.supports" autocomplete="off"
:included="isIncluded" clearable
:excluded="isExcluded" input-class="!bg-button-bg"
@toggle="toggleFilter" wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
@toggle-exclude="toggleNegativeFilter" />
/> <ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
</template> <div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-else> <template v-if="groupedOptions">
<template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`"> <SearchFilterGroup
<SearchFilterOption v-for="[groupName, options] in groupedOptions"
:option="option" :key="`${filterType.id}-group-${groupName}`"
:included="isIncluded(option)" :group-name="groupName"
:excluded="isExcluded(option)" :options="options"
:supports="filterType.supports" :supports="filterType.supports"
:has-sub-options="!!option.sub_options?.length" :included="isIncluded"
:expanded="isExpanded(option)" :excluded="isExcluded"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter" @toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter" @toggle-exclude="toggleNegativeFilter"
@toggle-expand="toggleExpand(option)" />
> </template>
<slot name="option" :filter="filterType" :option="option"> <template v-else>
<span <template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`">
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 }"
>
<SearchFilterOption <SearchFilterOption
v-for="subOption in option.sub_options" :option="option"
:key="`${filterType.id}-${subOption.id}`" :included="isIncluded(option)"
:option="subOption" :excluded="isExcluded(option)"
:included="isIncluded(subOption)"
:excluded="isExcluded(subOption)"
:supports="filterType.supports" :supports="filterType.supports"
:has-sub-options="!!option.sub_options?.length"
:expanded="isExpanded(option)"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter" @toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter" @toggle-exclude="toggleNegativeFilter"
@toggle-expand="toggleExpand(option)"
> >
<slot name="option" :filter="filterType" :option="subOption"> <slot name="option" :filter="filterType" :option="option">
<span class="truncate text-sm"> <span
{{ subOption.formatted_name ?? subOption.id }} 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> </span>
</slot> </slot>
</SearchFilterOption> </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>
</template> <button
<button v-if="filterType.display === 'expandable'"
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]"
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"
@click="showMore = !showMore" >
> <DropdownIcon
<DropdownIcon class="h-4 w-4 transition-transform"
class="h-4 w-4 transition-transform" :class="{ 'rotate-180': showMore }"
:class="{ 'rotate-180': showMore }" />
/> <span class="truncate text-sm">
<span class="truncate text-sm"> {{
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }} showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
</span> }}
</button> </span>
</div> </button>
</ScrollablePanel> </div>
</ScrollablePanel>
</template>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden"> <div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox <Checkbox
v-for="group in filterType.toggle_groups" v-for="group in filterType.toggle_groups"
@@ -219,6 +232,7 @@ import {
} from '../../utils/search' } from '../../utils/search'
import Accordion from '../base/Accordion.vue' import Accordion from '../base/Accordion.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index' import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchDependsOnFilter from './SearchDependsOnFilter.vue'
import SearchFilterGroup from './SearchFilterGroup.vue' import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue' import SearchFilterOption from './SearchFilterOption.vue'
@@ -233,11 +247,16 @@ const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedF
const props = defineProps<{ const props = defineProps<{
filterType: FilterType filterType: FilterType
projectType: string
buttonClass?: string buttonClass?: string
contentClass?: string contentClass?: string
innerPanelClass?: string innerPanelClass?: string
selectedProjectClass?: string
openByDefault?: boolean openByDefault?: boolean
providedFilters: FilterValue[] providedFilters: FilterValue[]
resultCount?: number
loading?: boolean
refreshing?: boolean
}>() }>()
defineOptions({ defineOptions({
@@ -58,6 +58,7 @@ export interface BrowseSearchState {
effectiveCurrentSortType: Ref<SortType> effectiveCurrentSortType: Ref<SortType>
loading: Ref<boolean> loading: Ref<boolean>
refreshing: Ref<boolean>
projectHits: ShallowRef<BrowseSearchResponse['projectHits']> projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
serverHits: ShallowRef<BrowseSearchResponse['serverHits']> serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
totalHits: Ref<number> totalHits: Ref<number>
@@ -186,6 +187,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
]) ])
const loading = ref(true) const loading = ref(true)
const refreshing = ref(false)
const projectHits = shallowRef<BrowseSearchResponse['projectHits']>([]) const projectHits = shallowRef<BrowseSearchResponse['projectHits']>([])
const serverHits = shallowRef<BrowseSearchResponse['serverHits']>([]) const serverHits = shallowRef<BrowseSearchResponse['serverHits']>([])
const totalHits = ref(0) const totalHits = ref(0)
@@ -297,6 +299,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
) )
watch(effectiveRequestParams, (newVal, oldVal) => { watch(effectiveRequestParams, (newVal, oldVal) => {
refreshing.value = true
debug('effectiveRequestParams changed', { debug('effectiveRequestParams changed', {
from: oldVal?.substring(0, 80), from: oldVal?.substring(0, 80),
to: newVal?.substring(0, 80), to: newVal?.substring(0, 80),
@@ -323,6 +326,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
} }
const version = ++searchVersion const version = ++searchVersion
refreshing.value = true
debug('refreshSearch start', { debug('refreshSearch start', {
version, version,
projectType: options.projectType.value, projectType: options.projectType.value,
@@ -363,11 +367,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
updateUrlParams() updateUrlParams()
loading.value = false loading.value = false
refreshing.value = false
} catch (err) { } catch (err) {
debug('refreshSearch error', err) debug('refreshSearch error', err)
console.error('Browse search error:', err) console.error('Browse search error:', err)
if (version === searchVersion) { if (version === searchVersion) {
loading.value = false loading.value = false
refreshing.value = false
} }
} }
} }
@@ -441,6 +447,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
effectiveSortTypes, effectiveSortTypes,
effectiveCurrentSortType, effectiveCurrentSortType,
loading, loading,
refreshing,
projectHits, projectHits,
serverHits, serverHits,
totalHits, totalHits,
@@ -237,6 +237,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
v-if="ctx.isServerType.value" v-if="ctx.isServerType.value"
v-model:selected-filters="ctx.serverCurrentFilters.value" v-model:selected-filters="ctx.serverCurrentFilters.value"
:filters="ctx.serverFilterTypes.value" :filters="ctx.serverFilterTypes.value"
:project-type="ctx.projectType.value"
:provided-filters="[]" :provided-filters="[]"
:overridden-provided-filter-types="[]" :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), (f) => f.display !== 'none' && !(ctx.hiddenFilterTypes?.value ?? []).includes(f.id),
) )
" "
:project-type="ctx.projectType.value"
:provided-filters="ctx.providedFilters?.value ?? []" :provided-filters="ctx.providedFilters?.value ?? []"
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value" :overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-message="lockedMessages?.providedBy" :provided-message="lockedMessages?.providedBy"
/> />
<div class="search"> <div class="search [overflow-anchor:none]">
<section v-if="ctx.loading.value" class="offline"> <section v-if="ctx.loading.value" class="offline">
<component :is="ctx.loadingComponent ?? LoadingIndicator" /> <component :is="ctx.loadingComponent ?? LoadingIndicator" />
</section> </section>
@@ -31,6 +31,7 @@ export interface BrowseManagerContext {
effectiveSortTypes: ComputedRef<readonly SortType[]> effectiveSortTypes: ComputedRef<readonly SortType[]>
effectiveCurrentSortType: Ref<SortType> effectiveCurrentSortType: Ref<SortType>
loading: Ref<boolean> loading: Ref<boolean>
refreshing: Ref<boolean>
projectHits: ShallowRef<BrowseSearchResponse['projectHits']> projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
serverHits: ShallowRef<BrowseSearchResponse['serverHits']> serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
totalHits: Ref<number> totalHits: Ref<number>
@@ -92,6 +92,9 @@ const buttonClass = computed(() => {
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3')) 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 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 { function hasProvidedFilter(filterId: string): boolean {
return (ctx.providedFilters?.value ?? []).some((filter) => filter.type === filterId) return (ctx.providedFilters?.value ?? []).some((filter) => filter.type === filterId)
@@ -104,6 +107,9 @@ function getFilterOpenByDefault(filterId: string): boolean {
if (hasProvidedFilter(filterId)) { if (hasProvidedFilter(filterId)) {
return true return true
} }
if (filterId === 'compatible_dependency_project_ids') {
return true
}
if (ctx.isServerType.value) { if (ctx.isServerType.value) {
return ![ return ![
'server_category_minecraft_server_meta', 'server_category_minecraft_server_meta',
@@ -214,10 +220,12 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-model:toggled-groups="ctx.serverToggledGroups.value" v-model:toggled-groups="ctx.serverToggledGroups.value"
:provided-filters="[]" :provided-filters="[]"
:filter-type="filterType" :filter-type="filterType"
:project-type="ctx.projectType.value"
:class="filterClass" :class="filterClass"
:button-class="buttonClass" :button-class="buttonClass"
:content-class="contentClass" :content-class="contentClass"
:inner-panel-class="innerPanelClass" :inner-panel-class="innerPanelClass"
:selected-project-class="selectedProjectClass"
:open-by-default="getFilterOpenByDefault(filterType.id)" :open-by-default="getFilterOpenByDefault(filterType.id)"
@on-open="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(false)" @on-open="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(true)" @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" v-model:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-filters="ctx.providedFilters?.value ?? []" :provided-filters="ctx.providedFilters?.value ?? []"
:filter-type="filter" :filter-type="filter"
:project-type="ctx.projectType.value"
:result-count="ctx.totalHits.value"
:loading="ctx.loading.value"
:refreshing="ctx.refreshing.value"
:class="filterClass" :class="filterClass"
:button-class="buttonClass" :button-class="buttonClass"
:content-class="contentClass" :content-class="contentClass"
:inner-panel-class="innerPanelClass" :inner-panel-class="innerPanelClass"
:selected-project-class="selectedProjectClass"
:open-by-default="getFilterOpenByDefault(filter.id)" :open-by-default="getFilterOpenByDefault(filter.id)"
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)" @on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)" @on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
+57
View File
@@ -4106,6 +4106,57 @@
"s.bg": { "s.bg": {
"defaultMessage": "Background task running" "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": { "search.filter.locked.default": {
"defaultMessage": "Filter locked" "defaultMessage": "Filter locked"
}, },
@@ -4172,6 +4223,9 @@
"search.filter_type.advanced.disclosure.telemetry": { "search.filter_type.advanced.disclosure.telemetry": {
"defaultMessage": "Telemetry" "defaultMessage": "Telemetry"
}, },
"search.filter_type.compatible_dependency_project_ids": {
"defaultMessage": "Depends on"
},
"search.filter_type.environment": { "search.filter_type.environment": {
"defaultMessage": "Environment" "defaultMessage": "Environment"
}, },
@@ -4187,6 +4241,9 @@
"search.filter_type.game_version.all_versions": { "search.filter_type.game_version.all_versions": {
"defaultMessage": "Show all versions" "defaultMessage": "Show all versions"
}, },
"search.filter_type.included_content": {
"defaultMessage": "Included content"
},
"search.filter_type.license": { "search.filter_type.license": {
"defaultMessage": "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 = { export const SearchableEmpty: Story = {
args: { args: {
options: [], options: [],
+89 -4
View File
@@ -105,10 +105,11 @@ export type FilterType = {
}[] }[]
searchable: boolean searchable: boolean
allows_custom_options?: 'and' | 'or' allows_custom_options?: 'and' | 'or'
custom_option_field?: string
ordering?: number ordering?: number
} & ( } & (
| { | {
display: 'all' | 'scrollable' | 'none' display: 'all' | 'scrollable' | 'none' | 'depends-on-project'
} }
| { | {
display: 'expandable' display: 'expandable'
@@ -122,6 +123,45 @@ export type FilterValue = {
negative?: boolean 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 = export type EnvironmentSearchOverride =
| { mode: 'include'; values: string[] } | { mode: 'include'; values: string[] }
| { mode: 'exclude'; values: string[] } | { mode: 'exclude'; values: string[] }
@@ -353,6 +393,14 @@ export function useSearch(
const overriddenProvidedFilterTypes = ref<string[]>([]) const overriddenProvidedFilterTypes = ref<string[]>([])
const { formatMessage, locale } = useVIntl() 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) => { const formatCategoryName = (categoryName: string) => {
return formatCategory(formatMessage, categoryName) return formatCategory(formatMessage, categoryName)
} }
@@ -396,6 +444,21 @@ export function useSearch(
const filterTypes: FilterType[] = [ const filterTypes: FilterType[] = [
...Object.values(categoryFilters), ...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', id: 'environment',
formatted_name: formatMessage( formatted_name: formatMessage(
@@ -704,6 +767,26 @@ export function useSearch(
const negativeByType: Record<string, string[]> = {} const negativeByType: Record<string, string[]> = {}
for (const filterValue of filterValues) { 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) const type = filters.value.find((type) => type.id === filterValue.type)
if (!type) { if (!type) {
console.error(`Filter type ${filterValue.type} not found`) console.error(`Filter type ${filterValue.type} not found`)
@@ -719,7 +802,9 @@ export function useSearch(
formatted_name: filterValue.option, formatted_name: filterValue.option,
icon: undefined, icon: undefined,
method: type.allows_custom_options, method: type.allows_custom_options,
value: filterValue.option, value: type.custom_option_field
? `${type.custom_option_field}:${filterValue.option}`
: filterValue.option,
} }
} else if (!option) { } else if (!option) {
console.error(`Filter option ${filterValue.option} not found`) console.error(`Filter option ${filterValue.option} not found`)
@@ -962,8 +1047,8 @@ export function useSearch(
currentFilters.value.forEach((filterValue) => { currentFilters.value.forEach((filterValue) => {
const type = filters.value.find((type) => type.id === filterValue.type) const type = filters.value.find((type) => type.id === filterValue.type)
const option = type ? findFilterOption(type.options, filterValue.option) : undefined const option = type ? findFilterOption(type.options, filterValue.option) : undefined
if (type && option) { if (type && (option || type.allows_custom_options)) {
const value = getOptionValue(option, filterValue.negative) const value = option ? getOptionValue(option, filterValue.negative) : filterValue.option
if (items[type.query_param]) { if (items[type.query_param]) {
items[type.query_param].push(value) items[type.query_param].push(value)
} else { } else {