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"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
:clearable="clearable"
:autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize"
@@ -38,6 +39,7 @@
@focusin="handleSearchFocus"
@focusout="handleSearchFocusout"
@click="handleSearchClick"
@clear="handleSearchInput"
>
<template v-if="showChevron" #right>
<ChevronLeftIcon
@@ -274,6 +276,7 @@ const props = withDefaults(
placeholder?: string
disabled?: boolean
searchable?: boolean
clearable?: boolean
searchPlaceholder?: string
listbox?: boolean
showChevron?: boolean
@@ -316,6 +319,7 @@ const props = withDefaults(
placeholder: 'Select an option',
disabled: false,
searchable: false,
clearable: false,
searchPlaceholder: 'Search...',
listbox: true,
showChevron: true,
@@ -8,6 +8,7 @@
:no-options-message="searchLoading ? loadingMessage : noResultsMessage"
:disable-search-filter="true"
:disabled="disabled"
:clearable="clearable"
show-icon-in-selected
@search-input="(query) => handleSearch(query)"
/>
@@ -55,6 +56,8 @@ const props = withDefaults(
noResultsMessage?: string
/** Whether the combobox is disabled */
disabled?: boolean
/** Whether to show a button for clearing the search input */
clearable?: boolean
/** Maximum number of results to show */
limit?: number
/** Project IDs to exclude from results */
@@ -5,6 +5,9 @@
:project-types="selectableProjectTypes"
:exclude-project-ids="dependencyProjectIds"
:search-placeholder="formatMessage(messages.searchContentPlaceholder)"
:show-chevron="false"
clearable
show-search-icon
@update:model-value="addIncludedProject"
/>
<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">
{{ dependentProjectMap.get(projectId)?.title ?? projectId }}
</span>
<button
type="button"
class="shrink-0 cursor-pointer border-0 bg-transparent p-0 text-sm transition-all"
:class="
excludedProjectIds.has(projectId)
? 'text-secondary opacity-100'
: 'text-secondary opacity-0 decoration-1 underline-offset-2 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-contrast'
"
@click="toggleProjectExcluded(projectId)"
>
{{
excludedProjectIds.has(projectId)
? formatMessage(messages.excluded)
: formatMessage(messages.exclude)
}}
</button>
<button
v-tooltip="formatMessage(messages.removeIncludedProjectTooltip)"
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 text-secondary hover:text-contrast"
:aria-label="
formatMessage(messages.removeIncludedProject, {
project: dependentProjectMap.get(projectId)?.title ?? projectId,
})
"
@click="removeIncludedProject(projectId)"
>
<XIcon class="size-5" />
</button>
<div class="flex items-center gap-1">
<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-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]"
:aria-label="
formatMessage(messages.removeIncludedProject, {
project: dependentProjectMap.get(projectId)?.title ?? projectId,
})
"
@click="removeIncludedProject(projectId)"
>
<XIcon class="size-4" aria-hidden="true" />
</button>
<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-1 transition-all"
: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>
</div>
</div>
</div>
</div>
<div v-else :class="innerPanelClass" class="flex flex-col gap-3">
<ProjectCombobox
v-show="!selectedProjectId || refreshing"
v-show="!selectedProjectId"
ref="projectCombobox"
:model-value="selectedProjectId"
:project-types="selectableProjectTypes"
:search-placeholder="formatMessage(messages.searchProjectPlaceholder)"
:show-chevron="false"
clearable
show-search-icon
@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">
<span>{{ formatMessage(messages.dependentCount, { count: resultCount ?? 0 }) }}</span>
<span>
{{
refreshing
? 'Loading...'
: formatMessage(messages.dependentCount, { count: resultCount ?? 0 })
}}
</span>
<button
class="border-none bg-transparent p-0 text-secondary cursor-pointer hover:text-contrast"
@click="setSelectedProjectId(undefined)"
@@ -86,27 +106,31 @@
<div class="truncate text-base font-bold text-contrast">
{{ selectedProject?.title ?? selectedProjectId }}
</div>
<MultiSelect
:model-value="dependencyTypes"
:options="dependencyTypeOptions"
:clearable="false"
fit-content
show-chevron
trigger-class="!rounded-none !bg-transparent !p-0"
checkbox-position="right"
:dropdown-min-width="220"
@update:model-value="setDependencyTypes"
>
<template #input-content="{ isOpen }">
<span class="flex items-center gap-0.5 text-sm text-secondary">
{{ dependencyTypeLabel }}
<DropdownIcon
class="size-4 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</span>
</template>
</MultiSelect>
<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>
</div>
</template>
@@ -114,13 +138,18 @@
</template>
<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 { computed, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
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 ProjectCombobox, {
type ProjectType as ProjectComboboxProjectType,
@@ -149,11 +178,13 @@ const selectableProjectTypes: ProjectComboboxProjectType[] = [
'plugin',
]
const isModpack = computed(() => props.projectType === 'modpack')
const dependencyProjectIds = computed(() =>
selectedFilters.value
.filter((filter) => filter.type === FILTER_TYPE_ID)
.map((filter) => filter.option),
)
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 = ref(new Set<string>())
@@ -181,20 +212,37 @@ const dependentProjectMap = computed(
)
const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null)
const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null)
const selectedProjectId = computed(
() => selectedFilters.value.find((filter) => filter.type === FILTER_TYPE_ID)?.option,
const selectedDependencyFilter = computed(() =>
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>[]>(() => [
{ 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(dependencyTypes.value)
const selectedTypes = new Set(draftDependencyTypes.value)
if (selectedTypes.size === dependencyTypeOptions.value.length) {
return formatMessage(messages.anyDependencyType)
}
@@ -207,15 +255,23 @@ const dependencyTypeLabel = computed(() => {
if (selectedTypes.has('optional') && selectedTypes.has('embedded')) {
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) {
const otherFilters = selectedFilters.value.filter((filter) => filter.type !== FILTER_TYPE_ID)
selectedFilters.value = projectId
? [...otherFilters, { type: FILTER_TYPE_ID, option: projectId }]
? [
...otherFilters,
{
type: FILTER_TYPE_ID,
option: formatDependencyProjectFilterOption(projectId, ['required']),
},
]
: otherFilters
dependencyTypes.value = ['required']
}
function addIncludedProject(projectId: string | undefined) {
@@ -227,7 +283,9 @@ function addIncludedProject(projectId: string | undefined) {
function removeIncludedProject(projectId: string) {
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)
nextExcludedProjectIds.delete(projectId)
@@ -244,9 +302,32 @@ function toggleProjectExcluded(projectId: string) {
excludedProjectIds.value = nextExcludedProjectIds
}
function setDependencyTypes(types: DependencyType[]) {
if (types.length > 0) {
dependencyTypes.value = types
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,
)
}
}
@@ -57,6 +57,7 @@ import {
type FilterType,
type FilterValue,
flattenFilterOptions,
parseDependencyProjectFilterOption,
} from '../../utils/search'
import TagItem from '../base/TagItem.vue'
@@ -99,7 +100,7 @@ const dependentProjectIds = computed(() =>
...new Set(
[...selectedFilters.value, ...props.providedFilters]
.filter((filter) => filter.type === 'compatible_dependency_project_ids')
.map((filter) => filter.option),
.map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
),
].sort(),
)
@@ -150,17 +151,20 @@ const items: ComputedRef<Item[]> = computed(() => {
)
return [
...optionItems,
...customValues.map((filter) => ({
type: type.id,
option: filter.option,
negative: filter.negative,
provided: props.providedFilters.some(
(provided) => provided.type === type.id && provided.option === filter.option,
),
formatted_name: formatMessage(dependentProjectMessage, {
project: dependentProjectNames.value.get(filter.option) ?? filter.option,
}),
})),
...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,
}),
}
}),
]
})
})
@@ -75,6 +75,7 @@
:project-type="projectType"
:inner-panel-class="innerPanelClass"
:result-count="resultCount"
:loading="loading"
:refreshing="refreshing"
/>
<template v-if="filterType.display !== 'depends-on-project'">
@@ -253,6 +254,7 @@ const props = defineProps<{
openByDefault?: boolean
providedFilters: FilterValue[]
resultCount?: number
loading?: boolean
refreshing?: boolean
}>()
@@ -249,6 +249,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
: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"
+59
View File
@@ -123,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[] }
@@ -728,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`)