mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
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:
co-authored by
Robin
Prospector
parent
5d54cfa0dc
commit
5181370293
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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) {
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user