feat: polish depends on filter

This commit is contained in:
tdgao
2026-08-14 12:30:24 -06:00
parent 21c11b8bf2
commit e2d5d4dd37
7 changed files with 238 additions and 84 deletions
@@ -24,6 +24,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"
@@ -38,6 +39,7 @@
@focusin="handleSearchFocus" @focusin="handleSearchFocus"
@focusout="handleSearchFocusout" @focusout="handleSearchFocusout"
@click="handleSearchClick" @click="handleSearchClick"
@clear="handleSearchInput"
> >
<template v-if="showChevron" #right> <template v-if="showChevron" #right>
<ChevronLeftIcon <ChevronLeftIcon
@@ -274,6 +276,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
@@ -316,6 +319,7 @@ const props = withDefaults(
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,
@@ -8,6 +8,7 @@
: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"
show-icon-in-selected show-icon-in-selected
@search-input="(query) => handleSearch(query)" @search-input="(query) => handleSearch(query)"
/> />
@@ -55,6 +56,8 @@ 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
/** 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 */
@@ -5,6 +5,9 @@
:project-types="selectableProjectTypes" :project-types="selectableProjectTypes"
:exclude-project-ids="dependencyProjectIds" :exclude-project-ids="dependencyProjectIds"
:search-placeholder="formatMessage(messages.searchContentPlaceholder)" :search-placeholder="formatMessage(messages.searchContentPlaceholder)"
:show-chevron="false"
clearable
show-search-icon
@update:model-value="addIncludedProject" @update:model-value="addIncludedProject"
/> />
<div v-if="dependencyProjectIds.length > 0" class="flex flex-col gap-1"> <div v-if="dependencyProjectIds.length > 0" class="flex flex-col gap-1">
@@ -23,50 +26,67 @@
<span class="min-w-0 flex-1 truncate font-medium text-contrast"> <span class="min-w-0 flex-1 truncate font-medium text-contrast">
{{ dependentProjectMap.get(projectId)?.title ?? projectId }} {{ dependentProjectMap.get(projectId)?.title ?? projectId }}
</span> </span>
<button <div class="flex items-center gap-1">
type="button" <button
class="shrink-0 cursor-pointer border-0 bg-transparent p-0 text-sm transition-all" v-tooltip="formatMessage(messages.removeIncludedProjectTooltip)"
:class=" type="button"
excludedProjectIds.has(projectId) class="flex shrink-0 cursor-pointer items-center justify-center rounded-xl border-0 bg-transparent px-2 py-1 text-secondary transition-all [@media(hover:hover)]:opacity-0 group-hover:opacity-100 hover:bg-button-bg hover:text-contrast active:scale-[0.96]"
? 'text-secondary opacity-100' :aria-label="
: 'text-secondary opacity-0 decoration-1 underline-offset-2 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-contrast' formatMessage(messages.removeIncludedProject, {
" project: dependentProjectMap.get(projectId)?.title ?? projectId,
@click="toggleProjectExcluded(projectId)" })
> "
{{ @click="removeIncludedProject(projectId)"
excludedProjectIds.has(projectId) >
? formatMessage(messages.excluded) <XIcon class="size-4" aria-hidden="true" />
: formatMessage(messages.exclude) </button>
}} <button
</button> v-tooltip="
<button excludedProjectIds.has(projectId)
v-tooltip="formatMessage(messages.removeIncludedProjectTooltip)" ? formatMessage(messages.excluded)
type="button" : formatMessage(messages.exclude)
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 text-secondary hover:text-contrast" "
:aria-label=" type="button"
formatMessage(messages.removeIncludedProject, { class="flex shrink-0 cursor-pointer items-center justify-center rounded-xl border-0 px-2 py-1 transition-all"
project: dependentProjectMap.get(projectId)?.title ?? projectId, :class="
}) excludedProjectIds.has(projectId)
" ? 'text-red opacity-100 bg-highlight-red'
@click="removeIncludedProject(projectId)" : 'text-secondary [@media(hover:hover)]:opacity-0 group-hover:opacity-100 bg-transparent hover:bg-button-bg hover:text-red active:scale-[0.96]'
> "
<XIcon class="size-5" /> :aria-label="
</button> excludedProjectIds.has(projectId)
? formatMessage(messages.excluded)
: formatMessage(messages.exclude)
"
@click="toggleProjectExcluded(projectId)"
>
<BanIcon class="size-4" aria-hidden="true" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div v-else :class="innerPanelClass" class="flex flex-col gap-3"> <div v-else :class="innerPanelClass" class="flex flex-col gap-3">
<ProjectCombobox <ProjectCombobox
v-show="!selectedProjectId || refreshing" v-show="!selectedProjectId"
ref="projectCombobox" ref="projectCombobox"
:model-value="selectedProjectId" :model-value="selectedProjectId"
:project-types="selectableProjectTypes" :project-types="selectableProjectTypes"
:search-placeholder="formatMessage(messages.searchProjectPlaceholder)" :search-placeholder="formatMessage(messages.searchProjectPlaceholder)"
:show-chevron="false"
clearable
show-search-icon
@update:model-value="setSelectedProjectId" @update:model-value="setSelectedProjectId"
/> />
<template v-if="selectedProjectId && !refreshing"> <template v-if="selectedProjectId && selectedProject">
<div class="flex items-center justify-between gap-3 px-2 text-secondary"> <div class="flex items-center justify-between gap-3 px-2 text-secondary">
<span>{{ formatMessage(messages.dependentCount, { count: resultCount ?? 0 }) }}</span> <span>
{{
refreshing
? 'Loading...'
: formatMessage(messages.dependentCount, { count: resultCount ?? 0 })
}}
</span>
<button <button
class="border-none bg-transparent p-0 text-secondary cursor-pointer hover:text-contrast" class="border-none bg-transparent p-0 text-secondary cursor-pointer hover:text-contrast"
@click="setSelectedProjectId(undefined)" @click="setSelectedProjectId(undefined)"
@@ -86,27 +106,31 @@
<div class="truncate text-base font-bold text-contrast"> <div class="truncate text-base font-bold text-contrast">
{{ selectedProject?.title ?? selectedProjectId }} {{ selectedProject?.title ?? selectedProjectId }}
</div> </div>
<MultiSelect <div class="relative right-2.5">
:model-value="dependencyTypes" <MultiSelect
:options="dependencyTypeOptions" :model-value="draftDependencyTypes"
:clearable="false" :options="dependencyTypeOptions"
fit-content :clearable="false"
show-chevron fit-content
trigger-class="!rounded-none !bg-transparent !p-0" show-chevron
checkbox-position="right" trigger-class="!rounded-none !bg-transparent !p-0"
:dropdown-min-width="220" checkbox-position="right"
@update:model-value="setDependencyTypes" :dropdown-min-width="220"
> @open="resetDraftDependencyTypes"
<template #input-content="{ isOpen }"> @close="commitDependencyTypes"
<span class="flex items-center gap-0.5 text-sm text-secondary"> @update:model-value="setDraftDependencyTypes"
{{ dependencyTypeLabel }} >
<DropdownIcon <template #input-content="{ isOpen }">
class="size-4 transition-transform" <span class="flex items-center gap-0.5 text-sm text-secondary ml-2.5">
:class="{ 'rotate-180': isOpen }" {{ dependencyTypeLabel }}
/> <DropdownIcon
</span> class="size-4 transition-transform"
</template> :class="{ 'rotate-180': isOpen }"
</MultiSelect> />
</span>
</template>
</MultiSelect>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -114,13 +138,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DropdownIcon, PackageIcon, XIcon } from '@modrinth/assets' import { BanIcon, DropdownIcon, PackageIcon, XIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n' import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers' import { injectModrinthClient } from '../../providers'
import type { FilterValue } from '../../utils/search' import {
type DependencyType,
type FilterValue,
formatDependencyProjectFilterOption,
parseDependencyProjectFilterOption,
} from '../../utils/search'
import MultiSelect, { type MultiSelectOption } from '../base/MultiSelect.vue' import MultiSelect, { type MultiSelectOption } from '../base/MultiSelect.vue'
import ProjectCombobox, { import ProjectCombobox, {
type ProjectType as ProjectComboboxProjectType, type ProjectType as ProjectComboboxProjectType,
@@ -149,11 +178,13 @@ const selectableProjectTypes: ProjectComboboxProjectType[] = [
'plugin', 'plugin',
] ]
const isModpack = computed(() => props.projectType === 'modpack') const isModpack = computed(() => props.projectType === 'modpack')
const dependencyProjectIds = computed(() => const dependencyProjectIds = computed(() => [
selectedFilters.value ...new Set(
.filter((filter) => filter.type === FILTER_TYPE_ID) selectedFilters.value
.map((filter) => filter.option), .filter((filter) => filter.type === FILTER_TYPE_ID)
) .map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
),
])
const pendingProjectId = ref<string>() const pendingProjectId = ref<string>()
const excludedProjectIds = ref(new Set<string>()) const excludedProjectIds = ref(new Set<string>())
@@ -181,20 +212,37 @@ const dependentProjectMap = computed(
) )
const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null) const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null)
const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null) const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null)
const selectedProjectId = computed( const selectedDependencyFilter = computed(() =>
() => selectedFilters.value.find((filter) => filter.type === FILTER_TYPE_ID)?.option, selectedFilters.value.find((filter) => filter.type === FILTER_TYPE_ID),
)
const selectedProjectId = computed(() =>
selectedDependencyFilter.value
? parseDependencyProjectFilterOption(selectedDependencyFilter.value.option).projectId
: undefined,
) )
type DependencyType = 'required' | 'optional' | 'embedded' 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 dependencyTypes = ref<DependencyType[]>(['required'])
const dependencyTypeOptions = computed<MultiSelectOption<DependencyType>[]>(() => [ const dependencyTypeOptions = computed<MultiSelectOption<DependencyType>[]>(() => [
{ value: 'required', label: formatMessage(messages.required) }, { value: 'required', label: formatMessage(messages.required) },
{ value: 'optional', label: formatMessage(messages.optional) }, { value: 'optional', label: formatMessage(messages.optional) },
{ value: 'embedded', label: formatMessage(messages.embedded) }, { value: 'embedded', label: formatMessage(messages.embedded) },
]) ])
const dependencyTypeLabel = computed(() => { const dependencyTypeLabel = computed(() => {
const selectedTypes = new Set(dependencyTypes.value) const selectedTypes = new Set(draftDependencyTypes.value)
if (selectedTypes.size === dependencyTypeOptions.value.length) { if (selectedTypes.size === dependencyTypeOptions.value.length) {
return formatMessage(messages.anyDependencyType) return formatMessage(messages.anyDependencyType)
} }
@@ -207,15 +255,23 @@ const dependencyTypeLabel = computed(() => {
if (selectedTypes.has('optional') && selectedTypes.has('embedded')) { if (selectedTypes.has('optional') && selectedTypes.has('embedded')) {
return formatMessage(messages.optionalOrEmbedded) return formatMessage(messages.optionalOrEmbedded)
} }
return dependencyTypeOptions.value.find((option) => selectedTypes.has(option.value))?.label ?? '' return (
dependencyTypeOptions.value.find((option) => selectedTypes.has(option.value))?.label ??
'Select type'
)
}) })
function setSelectedProjectId(projectId: string | undefined) { function setSelectedProjectId(projectId: string | undefined) {
const otherFilters = selectedFilters.value.filter((filter) => filter.type !== FILTER_TYPE_ID) const otherFilters = selectedFilters.value.filter((filter) => filter.type !== FILTER_TYPE_ID)
selectedFilters.value = projectId selectedFilters.value = projectId
? [...otherFilters, { type: FILTER_TYPE_ID, option: projectId }] ? [
...otherFilters,
{
type: FILTER_TYPE_ID,
option: formatDependencyProjectFilterOption(projectId, ['required']),
},
]
: otherFilters : otherFilters
dependencyTypes.value = ['required']
} }
function addIncludedProject(projectId: string | undefined) { function addIncludedProject(projectId: string | undefined) {
@@ -227,7 +283,9 @@ function addIncludedProject(projectId: string | undefined) {
function removeIncludedProject(projectId: string) { function removeIncludedProject(projectId: string) {
selectedFilters.value = selectedFilters.value.filter( selectedFilters.value = selectedFilters.value.filter(
(filter) => filter.type !== FILTER_TYPE_ID || filter.option !== projectId, (filter) =>
filter.type !== FILTER_TYPE_ID ||
parseDependencyProjectFilterOption(filter.option).projectId !== projectId,
) )
const nextExcludedProjectIds = new Set(excludedProjectIds.value) const nextExcludedProjectIds = new Set(excludedProjectIds.value)
nextExcludedProjectIds.delete(projectId) nextExcludedProjectIds.delete(projectId)
@@ -244,9 +302,32 @@ function toggleProjectExcluded(projectId: string) {
excludedProjectIds.value = nextExcludedProjectIds excludedProjectIds.value = nextExcludedProjectIds
} }
function setDependencyTypes(types: DependencyType[]) { function resetDraftDependencyTypes() {
if (types.length > 0) { draftDependencyTypes.value = [...dependencyTypes.value]
dependencyTypes.value = types }
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,
)
} }
} }
@@ -57,6 +57,7 @@ import {
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'
@@ -99,7 +100,7 @@ const dependentProjectIds = computed(() =>
...new Set( ...new Set(
[...selectedFilters.value, ...props.providedFilters] [...selectedFilters.value, ...props.providedFilters]
.filter((filter) => filter.type === 'compatible_dependency_project_ids') .filter((filter) => filter.type === 'compatible_dependency_project_ids')
.map((filter) => filter.option), .map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
), ),
].sort(), ].sort(),
) )
@@ -150,17 +151,20 @@ const items: ComputedRef<Item[]> = computed(() => {
) )
return [ return [
...optionItems, ...optionItems,
...customValues.map((filter) => ({ ...customValues.map((filter) => {
type: type.id, const projectId = parseDependencyProjectFilterOption(filter.option).projectId
option: filter.option, return {
negative: filter.negative, type: type.id,
provided: props.providedFilters.some( option: filter.option,
(provided) => provided.type === type.id && provided.option === filter.option, negative: filter.negative,
), provided: props.providedFilters.some(
formatted_name: formatMessage(dependentProjectMessage, { (provided) => provided.type === type.id && provided.option === filter.option,
project: dependentProjectNames.value.get(filter.option) ?? filter.option, ),
}), formatted_name: formatMessage(dependentProjectMessage, {
})), project: dependentProjectNames.value.get(projectId) ?? projectId,
}),
}
}),
] ]
}) })
}) })
@@ -75,6 +75,7 @@
:project-type="projectType" :project-type="projectType"
:inner-panel-class="innerPanelClass" :inner-panel-class="innerPanelClass"
:result-count="resultCount" :result-count="resultCount"
:loading="loading"
:refreshing="refreshing" :refreshing="refreshing"
/> />
<template v-if="filterType.display !== 'depends-on-project'"> <template v-if="filterType.display !== 'depends-on-project'">
@@ -253,6 +254,7 @@ const props = defineProps<{
openByDefault?: boolean openByDefault?: boolean
providedFilters: FilterValue[] providedFilters: FilterValue[]
resultCount?: number resultCount?: number
loading?: boolean
refreshing?: boolean refreshing?: boolean
}>() }>()
@@ -249,6 +249,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
:filter-type="filter" :filter-type="filter"
:project-type="ctx.projectType.value" :project-type="ctx.projectType.value"
:result-count="ctx.totalHits.value" :result-count="ctx.totalHits.value"
:loading="ctx.loading.value"
:refreshing="ctx.refreshing.value" :refreshing="ctx.refreshing.value"
:class="filterClass" :class="filterClass"
:button-class="buttonClass" :button-class="buttonClass"
+59
View File
@@ -123,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[] }
@@ -728,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`)