mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 17:14:50 +00:00
feat: polish depends on filter
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user