mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 01:26:23 +00:00
feat: implement dependent projects + members breakdown/filter (#6395)
* feat: implement dependent project breakdown * implement dependent project type filter * feat: show dependent on column if there are multiple projects selected * feat: different tooltip for when both project version and dependent breakdowns are on * feat: implement dependents query filter * feat: add project icon to query filter * fix: dont show dependent on column when project breakdown is selected * feat: add org icon * remove: dependent on project column and project version project column * refactor: pnpm prepr * fix: all project selection gets unselected when reload page * pnpm prepr * fix: still using old search formatter * feat: implement projects in table click to link to project page * feat: handle analytics values that dont have a dependent project as a "No dependent" row * pnpm prepr * feat: remove org icon for dependents projects and change unknown label * feat: do not include unknown rows for showing top 8 * feat: separate out no dependents based on download reason * fix: use compatible dependencies for query filter options * feat: implement members breakdown/filter frontend (#6470) * feat: add link to user in table * fix: do not allow breakdowns to occur without shared stats * pnpm prepr * fix: i18n and passing params for formatter
This commit is contained in:
@@ -25,10 +25,25 @@
|
||||
|
||||
<template #option="{ category, option, selected }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<template v-if="category.key === 'version_id'">
|
||||
<span
|
||||
v-if="category.key === 'user_id'"
|
||||
v-tooltip="option.label"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full text-primary"
|
||||
:class="selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
<img
|
||||
v-if="getUserAvatarUrl(option.value)"
|
||||
:src="getUserAvatarUrl(option.value)"
|
||||
:alt="option.label"
|
||||
class="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
<UserIcon v-else class="h-full w-full" />
|
||||
</span>
|
||||
<template
|
||||
v-for="metadata in getFilterOptionProjectMetadata(category.key, option.value)"
|
||||
:key="`${category.key}-${option.value}-${metadata.name}`"
|
||||
>
|
||||
<span
|
||||
v-for="metadata in getProjectVersionOptionProjectMetadata(option.value)"
|
||||
:key="`${option.value}-${metadata.name}`"
|
||||
v-tooltip="metadata.name"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
@@ -42,6 +57,8 @@
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
:ref="(element) => setFilterOptionLabelRef(category.key, option.value, element)"
|
||||
v-tooltip="getFilterOptionLabelTooltip(category.key, option.value, option.label)"
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
@@ -179,16 +196,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BoxIcon } from '@modrinth/assets'
|
||||
import { BoxIcon, UserIcon } from '@modrinth/assets'
|
||||
import {
|
||||
buildDependentsSearchFilters,
|
||||
DropdownFilterBar,
|
||||
type DropdownFilterBarCategory,
|
||||
type DropdownFilterBarOption,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
type ProjectType,
|
||||
Tabs,
|
||||
type TabsTab,
|
||||
type TabsValue,
|
||||
truncatedTooltip,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import { useFormattedCountries } from '@/composables/country.ts'
|
||||
import {
|
||||
@@ -258,6 +283,8 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
hasProjectContext,
|
||||
projects,
|
||||
@@ -277,6 +304,9 @@ const {
|
||||
versionPublishedDatesById,
|
||||
versionProjectNamesById,
|
||||
versionProjectIconUrlsById,
|
||||
projectNamesById,
|
||||
userNamesById,
|
||||
userAvatarUrlsById,
|
||||
getVersionDisplayName,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formattedCountries = useFormattedCountries()
|
||||
@@ -290,6 +320,17 @@ const gameVersionTypeTabs = computed<TabsTab[]>(() => [
|
||||
{ value: 'release', label: formatMessage(analyticsMessages.releaseTab) },
|
||||
{ value: 'all', label: formatMessage(analyticsMessages.allTab) },
|
||||
])
|
||||
const dependentProjectSearchProjectTypes: readonly ProjectType[] = [
|
||||
'mod',
|
||||
'modpack',
|
||||
'resourcepack',
|
||||
'shader',
|
||||
'datapack',
|
||||
'plugin',
|
||||
]
|
||||
const dependentProjectInitialSearchLimit = 100
|
||||
const dependentProjectQuerySearchLimit = 500
|
||||
const dependentProjectSearchDebounceMs = 250
|
||||
const resolvedAddLabel = computed(
|
||||
() => props.addLabel ?? formatMessage(analyticsMessages.addButton),
|
||||
)
|
||||
@@ -306,14 +347,16 @@ const projectStatusFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
})),
|
||||
)
|
||||
const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value))
|
||||
const effectiveSelectedProjectCount = computed(
|
||||
() =>
|
||||
projects.value.filter(
|
||||
const effectiveSelectedProjectIds = computed(() =>
|
||||
projects.value
|
||||
.filter(
|
||||
(project) =>
|
||||
selectedProjectIdSet.value.has(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
|
||||
).length,
|
||||
)
|
||||
.map((project) => project.id),
|
||||
)
|
||||
const effectiveSelectedProjectCount = computed(() => effectiveSelectedProjectIds.value.length)
|
||||
const showProjectVersionProjectIcons = computed(() => effectiveSelectedProjectCount.value > 1)
|
||||
const defaultSelectedBreakdown = computed(() =>
|
||||
getDefaultAnalyticsBreakdownPresets(selectedProjectIds.value),
|
||||
@@ -330,12 +373,72 @@ const projectVersionFilterOptions = shallowRef<ProjectVersionFilterOption[]>([])
|
||||
const projectVersionFilterOptionProjectMetadataById = shallowRef(
|
||||
new Map<string, ProjectVersionFilterOptionProjectMetadata[]>(),
|
||||
)
|
||||
const dependentProjectSearchInput = ref('')
|
||||
const dependentProjectSearchQuery = ref('')
|
||||
const draftSelectedFilters = ref<AnalyticsSelectedFilters>(
|
||||
cloneSelectedFilters(selectedFilters.value),
|
||||
)
|
||||
let selectedFiltersCommitRequestId = 0
|
||||
let projectVersionFilterOptionsCacheKey = ''
|
||||
let projectVersionFilterOptionProjectMetadataCacheKey = ''
|
||||
let dependentProjectSearchDebounceTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const filterOptionLabelElements = new Map<string, HTMLElement>()
|
||||
const filterOptionLabelRefUpdateToken = ref(0)
|
||||
|
||||
const dependentProjectSearchFilters = computed(() =>
|
||||
buildDependentsSearchFilters(
|
||||
dependentProjectSearchProjectTypes,
|
||||
effectiveSelectedProjectIds.value,
|
||||
),
|
||||
)
|
||||
const dependentProjectSearchQueryValue = computed(() => dependentProjectSearchQuery.value.trim())
|
||||
const dependentProjectSearchResultLimit = computed(() =>
|
||||
dependentProjectSearchQueryValue.value.length > 0
|
||||
? dependentProjectQuerySearchLimit
|
||||
: dependentProjectInitialSearchLimit,
|
||||
)
|
||||
const selectedDependentProjectIds = computed(() => [
|
||||
...new Set([
|
||||
...selectedFilters.value.dependent_project_id,
|
||||
...draftSelectedFilters.value.dependent_project_id,
|
||||
]),
|
||||
])
|
||||
const { data: dependentProjectSearchResults, error: dependentProjectSearchError } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'analytics',
|
||||
'query-filter',
|
||||
'dependent-project-search',
|
||||
dependentProjectSearchFilters.value,
|
||||
dependentProjectSearchQueryValue.value,
|
||||
dependentProjectSearchResultLimit.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
client.labrinth.projects_v2.search({
|
||||
query: dependentProjectSearchQueryValue.value || undefined,
|
||||
new_filters: dependentProjectSearchFilters.value,
|
||||
limit: dependentProjectSearchResultLimit.value,
|
||||
index: 'downloads',
|
||||
}),
|
||||
enabled: computed(
|
||||
() =>
|
||||
effectiveSelectedProjectIds.value.length > 0 &&
|
||||
dependentProjectSearchFilters.value.length > 0,
|
||||
),
|
||||
placeholderData: (previousData) => previousData,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const { data: selectedDependentProjects } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'analytics',
|
||||
'query-filter',
|
||||
'selected-dependent-projects',
|
||||
selectedDependentProjectIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(selectedDependentProjectIds.value),
|
||||
enabled: computed(() => selectedDependentProjectIds.value.length > 0),
|
||||
placeholderData: [],
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const selectedFilterValue = computed<Record<string, string[]>>({
|
||||
get: () => getSelectedFilterBarValue(),
|
||||
@@ -374,6 +477,27 @@ watch(queryResetToken, () => {
|
||||
clearDownloadsThresholds()
|
||||
})
|
||||
|
||||
watch(dependentProjectSearchInput, (query) => {
|
||||
if (dependentProjectSearchDebounceTimeout) {
|
||||
clearTimeout(dependentProjectSearchDebounceTimeout)
|
||||
}
|
||||
|
||||
dependentProjectSearchDebounceTimeout = setTimeout(() => {
|
||||
dependentProjectSearchQuery.value = query.trim()
|
||||
dependentProjectSearchDebounceTimeout = null
|
||||
}, dependentProjectSearchDebounceMs)
|
||||
})
|
||||
|
||||
watch(dependentProjectSearchError, (error) => {
|
||||
if (!error) return
|
||||
|
||||
addNotification({
|
||||
title: 'Dependent projects failed to load',
|
||||
text: getDependentProjectSearchErrorMessage(error),
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedFilters,
|
||||
(nextFilters, previousFilters) => {
|
||||
@@ -475,6 +599,12 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (dependentProjectSearchDebounceTimeout) {
|
||||
clearTimeout(dependentProjectSearchDebounceTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
async function scheduleSelectedFiltersCommit() {
|
||||
const requestId = ++selectedFiltersCommitRequestId
|
||||
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
|
||||
@@ -577,6 +707,40 @@ const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
|
||||
label: formatMessage(analyticsBreakdownMessages.loader),
|
||||
options: withSelectedOptions('loader_type', loaderTypeFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'dependent_project_id',
|
||||
label: formatMessage(analyticsBreakdownMessages.dependentProjectDownload),
|
||||
searchable: true,
|
||||
disableLocalOptionsFilter: true,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchDependentProjectsPlaceholder),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
onSearchQueryChange: setDependentProjectSearchInput,
|
||||
options: withSelectedOptions('dependent_project_id', dependentProjectFilterOptions.value),
|
||||
submenuClass: 'w-fit min-w-[18rem]',
|
||||
previewDropdownWidth: 'fit-content',
|
||||
previewDropdownMinWidth: '18rem',
|
||||
},
|
||||
{
|
||||
key: 'dependent_project_type',
|
||||
label: formatMessage(analyticsBreakdownMessages.dependentProjectType),
|
||||
options: withSelectedOptions(
|
||||
'dependent_project_type',
|
||||
dependentProjectTypeFilterOptions.value,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user_id',
|
||||
label: formatMessage(analyticsBreakdownMessages.members),
|
||||
searchable: memberFilterOptions.value.length > 6,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchMembersPlaceholder),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
options: withSelectedOptions('user_id', memberFilterOptions.value),
|
||||
submenuClass: 'w-fit min-w-[14rem]',
|
||||
previewDropdownWidth: 'fit-content',
|
||||
previewDropdownMinWidth: '14rem',
|
||||
},
|
||||
)
|
||||
|
||||
return categories.filter((category) =>
|
||||
@@ -636,6 +800,16 @@ const downloadReasonFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const memberFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.userIds
|
||||
.map((userId) => ({
|
||||
value: userId,
|
||||
label: getUserFilterOptionLabel(userId),
|
||||
searchTerms: [userId],
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
const gameVersionFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.gameVersions
|
||||
.filter((gameVersion) => {
|
||||
@@ -668,6 +842,45 @@ const loaderTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
const dependentProjectSearchResultsById = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(dependentProjectSearchResults.value?.hits ?? []).map((project) => [
|
||||
project.project_id,
|
||||
project,
|
||||
]),
|
||||
),
|
||||
)
|
||||
const selectedDependentProjectsById = computed(
|
||||
() => new Map((selectedDependentProjects.value ?? []).map((project) => [project.id, project])),
|
||||
)
|
||||
|
||||
const dependentProjectFilterOptions = computed<DropdownFilterBarOption[]>(() => {
|
||||
const optionsById = new Map<string, DropdownFilterBarOption>()
|
||||
|
||||
for (const project of dependentProjectSearchResults.value?.hits ?? []) {
|
||||
optionsById.set(project.project_id, {
|
||||
value: project.project_id,
|
||||
label: project.title,
|
||||
searchTerms: [project.project_id, project.slug, project.author].filter(
|
||||
(term): term is string => Boolean(term),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return [...optionsById.values()]
|
||||
})
|
||||
|
||||
const dependentProjectTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.dependentProjectTypes
|
||||
.map((projectType) => ({
|
||||
value: projectType,
|
||||
label: getProjectTypeFilterOptionLabel(projectType),
|
||||
searchTerms: [projectType],
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
function isAnalyticsFilterValueCategory(
|
||||
categoryKey: string,
|
||||
): categoryKey is AnalyticsFilterValueCategory {
|
||||
@@ -697,15 +910,77 @@ function getMissingSelectedOptionLabel(
|
||||
if (categoryKey === 'download_reason') {
|
||||
return getDownloadReasonFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'user_id') {
|
||||
return getUserFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'user_agent') {
|
||||
return (value) => getDownloadSourceLabel(value, formatMessage)
|
||||
}
|
||||
if (categoryKey === 'loader_type') {
|
||||
return getLoaderTypeFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'dependent_project_id') {
|
||||
return getDependentProjectFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'dependent_project_type') {
|
||||
return getProjectTypeFilterOptionLabel
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getUserAvatarUrl(userId: string): string | undefined {
|
||||
return userAvatarUrlsById.value.get(userId)
|
||||
}
|
||||
|
||||
function setFilterOptionLabelRef(
|
||||
categoryKey: string,
|
||||
optionValue: string,
|
||||
element: Element | ComponentPublicInstance | null,
|
||||
) {
|
||||
const key = getFilterOptionLabelElementKey(categoryKey, optionValue)
|
||||
if (element instanceof HTMLElement) {
|
||||
if (filterOptionLabelElements.get(key) === element) {
|
||||
return
|
||||
}
|
||||
|
||||
filterOptionLabelElements.set(key, element)
|
||||
filterOptionLabelRefUpdateToken.value++
|
||||
return
|
||||
}
|
||||
|
||||
if (filterOptionLabelElements.delete(key)) {
|
||||
filterOptionLabelRefUpdateToken.value++
|
||||
}
|
||||
}
|
||||
|
||||
function getFilterOptionLabelTooltip(
|
||||
categoryKey: string,
|
||||
optionValue: string,
|
||||
label: string,
|
||||
): string | undefined {
|
||||
void filterOptionLabelRefUpdateToken.value
|
||||
|
||||
return truncatedTooltip(
|
||||
filterOptionLabelElements.get(getFilterOptionLabelElementKey(categoryKey, optionValue)),
|
||||
label,
|
||||
)
|
||||
}
|
||||
|
||||
function getFilterOptionLabelElementKey(categoryKey: string, optionValue: string) {
|
||||
return `${categoryKey}\x1f${optionValue}`
|
||||
}
|
||||
|
||||
function getFilterOptionProjectMetadata(categoryKey: string, optionValue: string) {
|
||||
if (categoryKey === 'version_id') {
|
||||
return getProjectVersionOptionProjectMetadata(optionValue)
|
||||
}
|
||||
if (categoryKey === 'dependent_project_id') {
|
||||
return getDependentProjectOptionProjectMetadata(optionValue)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getProjectVersionOptionProjectMetadata(versionId: string) {
|
||||
if (!showProjectVersionProjectIcons.value) {
|
||||
return []
|
||||
@@ -714,6 +989,24 @@ function getProjectVersionOptionProjectMetadata(versionId: string) {
|
||||
return projectVersionFilterOptionProjectMetadataById.value.get(versionId) ?? []
|
||||
}
|
||||
|
||||
function getDependentProjectOptionProjectMetadata(projectId: string) {
|
||||
const searchResult = dependentProjectSearchResultsById.value.get(projectId)
|
||||
const selectedProject = selectedDependentProjectsById.value.get(projectId)
|
||||
const name =
|
||||
searchResult?.title ??
|
||||
selectedProject?.title ??
|
||||
projectNamesById.value.get(projectId) ??
|
||||
projectId
|
||||
const iconUrl = searchResult?.icon_url ?? selectedProject?.icon_url
|
||||
|
||||
return [
|
||||
{
|
||||
name,
|
||||
...(iconUrl ? { iconUrl } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function getCountryFilterOptionLabel(countryCode: string): string {
|
||||
const normalizedCode = countryCode.trim().toUpperCase()
|
||||
if (normalizedCode === 'XX') {
|
||||
@@ -731,10 +1024,51 @@ function getLoaderTypeFilterOptionLabel(loaderType: string): string {
|
||||
return formatAnalyticsLoaderLabel(loaderType, formatMessage)
|
||||
}
|
||||
|
||||
function getProjectTypeFilterOptionLabel(projectType: string): string {
|
||||
return formatProjectType(projectType)
|
||||
}
|
||||
|
||||
function getDependentProjectFilterOptionLabel(projectId: string): string {
|
||||
return (
|
||||
dependentProjectSearchResultsById.value.get(projectId)?.title ??
|
||||
selectedDependentProjectsById.value.get(projectId)?.title ??
|
||||
projectNamesById.value.get(projectId) ??
|
||||
projectId
|
||||
)
|
||||
}
|
||||
|
||||
function getDownloadReasonFilterOptionLabel(reason: string): string {
|
||||
return formatAnalyticsDownloadReasonLabel(reason, formatMessage)
|
||||
}
|
||||
|
||||
function getUserFilterOptionLabel(userId: string): string {
|
||||
return userNamesById.value.get(userId) ?? userId
|
||||
}
|
||||
|
||||
function setDependentProjectSearchInput(query: string) {
|
||||
dependentProjectSearchInput.value = query
|
||||
}
|
||||
|
||||
function getDependentProjectSearchErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const dataDescription = (error as { data?: { description?: unknown } }).data?.description
|
||||
if (typeof dataDescription === 'string' && dataDescription.length > 0) {
|
||||
return dataDescription
|
||||
}
|
||||
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string' && message.length > 0) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'string' && error.length > 0) {
|
||||
return error
|
||||
}
|
||||
|
||||
return 'Please try searching again or changing the selected projects.'
|
||||
}
|
||||
|
||||
function getDateTimestamp(date: string | undefined): number | undefined {
|
||||
if (!date) {
|
||||
return undefined
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<LayersIcon
|
||||
<UserIcon
|
||||
class="h-5 w-5 shrink-0 text-primary"
|
||||
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
|
||||
/>
|
||||
@@ -290,7 +290,7 @@
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<LayersIcon
|
||||
<UserIcon
|
||||
class="h-5 w-5 shrink-0 text-primary"
|
||||
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
|
||||
/>
|
||||
@@ -449,6 +449,7 @@ import {
|
||||
ClockIcon,
|
||||
FolderOpenIcon,
|
||||
LayersIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
@@ -499,7 +500,7 @@ import {
|
||||
import TimeFramePicker from './TimeframePicker.vue'
|
||||
|
||||
const QUERY_BUILDER_DROPDOWN_MAX_HEIGHT = 500
|
||||
const QUERY_BUILDER_DROPDOWN_MIN_WIDTH = '12rem'
|
||||
const QUERY_BUILDER_DROPDOWN_MIN_WIDTH = '14rem'
|
||||
const analyticsQueryChipTriggerClass = 'h-10 '
|
||||
const analyticsQueryAddFilterButtonClass = '!h-10 max-w-full !w-max !px-3.5 flex !gap-2'
|
||||
const projectOptionCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
@@ -1008,15 +1009,34 @@ const breakdownOptions = computed<MultiSelectOption<Exclude<AnalyticsBreakdownPr
|
||||
value: 'game_version',
|
||||
label: formatAnalyticsBreakdownLabel('game_version', formatMessage),
|
||||
},
|
||||
{
|
||||
value: 'dependent_project_download',
|
||||
label: formatAnalyticsBreakdownLabel('dependent_project_download', formatMessage),
|
||||
},
|
||||
{
|
||||
value: 'user_id',
|
||||
label: formatAnalyticsBreakdownLabel('user_id', formatMessage),
|
||||
},
|
||||
)
|
||||
|
||||
return options.map((option) => ({
|
||||
...option,
|
||||
disabled: hasReachedBreakdownLimit && !selectedBreakdownSet.has(option.value),
|
||||
}))
|
||||
return options.map((option) => {
|
||||
const isSelected = selectedBreakdownSet.has(option.value)
|
||||
return {
|
||||
...option,
|
||||
disabled:
|
||||
!isSelected && (hasReachedBreakdownLimit || !canSelectBreakdownOption(option.value)),
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function canSelectBreakdownOption(breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>): boolean {
|
||||
return getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
[...selectedBreakdownValue.value, breakdown],
|
||||
selectedProjectIds.value,
|
||||
).includes(breakdown)
|
||||
}
|
||||
|
||||
function getBreakdownOptionLabel(breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>): string {
|
||||
return (
|
||||
breakdownOptions.value.find((option) => option.value === breakdown)?.label ??
|
||||
@@ -1136,6 +1156,17 @@ function withBreakdownFields(
|
||||
downloads.push('reason')
|
||||
}
|
||||
break
|
||||
case 'user_id':
|
||||
if (includesStat(breakdownStats, 'revenue') && includesStat(enabledStats, 'revenue')) {
|
||||
revenue.push('user_id')
|
||||
}
|
||||
break
|
||||
case 'dependent_project_download':
|
||||
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
|
||||
downloads.push('dependent_project_id')
|
||||
downloads.push('reason')
|
||||
}
|
||||
break
|
||||
case 'version_id':
|
||||
if (includesStat(breakdownStats, 'downloads') && includesStat(enabledStats, 'downloads')) {
|
||||
downloads.push('version_id')
|
||||
@@ -1264,6 +1295,12 @@ function buildMetricFilters(
|
||||
filters.game_version,
|
||||
),
|
||||
loader: getFilterValuesForStat('loader_type', 'downloads', enabledStats, filters.loader_type),
|
||||
dependent_project_id: getFilterValuesForStat(
|
||||
'dependent_project_id',
|
||||
'downloads',
|
||||
enabledStats,
|
||||
filters.dependent_project_id,
|
||||
),
|
||||
},
|
||||
playtime: {
|
||||
country: getFilterValuesForStat('country', 'playtime', enabledStats, filters.country),
|
||||
@@ -1281,7 +1318,9 @@ function buildMetricFilters(
|
||||
),
|
||||
loader: getFilterValuesForStat('loader_type', 'playtime', enabledStats, filters.loader_type),
|
||||
},
|
||||
revenue: {},
|
||||
revenue: {
|
||||
user_id: getFilterValuesForStat('user_id', 'revenue', enabledStats, filters.user_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1295,6 +1334,15 @@ const fetchRequest = computed<Labrinth.Analytics.v3.FetchRequest>(() => {
|
||||
|
||||
const bucketBy = withBreakdownFields(selectedBreakdowns.value, selectedFilters.value)
|
||||
const filterBy = buildMetricFilters(selectedBreakdowns.value, selectedFilters.value)
|
||||
if (
|
||||
includesStat(
|
||||
getEnabledAnalyticsStatsForState(selectedBreakdowns.value, selectedFilters.value),
|
||||
'revenue',
|
||||
) &&
|
||||
!bucketBy.revenue.includes('user_id')
|
||||
) {
|
||||
bucketBy.revenue.push('user_id')
|
||||
}
|
||||
const filteredProjectIds = getProjectIdsMatchingStatusFilter(
|
||||
selectedProjectIds.value,
|
||||
projectStatusById.value,
|
||||
|
||||
+68
-6
@@ -2,6 +2,7 @@ import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsQueryFilterCategory,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
AnalyticsSelectedFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
@@ -13,6 +14,10 @@ export type AnalyticsDashboardDimension =
|
||||
| 'monetization'
|
||||
| 'user_agent'
|
||||
| 'download_reason'
|
||||
| 'user_id'
|
||||
| 'dependent_project_download'
|
||||
| 'dependent_project_id'
|
||||
| 'dependent_project_type'
|
||||
| 'game_version'
|
||||
| 'loader_type'
|
||||
|
||||
@@ -23,9 +28,12 @@ export const FILTER_VALUE_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'pro
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'user_id',
|
||||
'version_id',
|
||||
'game_version',
|
||||
'loader_type',
|
||||
'dependent_project_id',
|
||||
'dependent_project_type',
|
||||
]
|
||||
|
||||
const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [
|
||||
@@ -45,8 +53,12 @@ const ANALYTICS_STATS_BY_DIMENSION: Record<
|
||||
monetization: ['views', 'downloads'],
|
||||
user_agent: ['downloads'],
|
||||
download_reason: ['downloads'],
|
||||
user_id: ['revenue'],
|
||||
dependent_project_download: ['downloads'],
|
||||
dependent_project_type: ['downloads'],
|
||||
game_version: ['downloads', 'playtime'],
|
||||
loader_type: ['downloads', 'playtime'],
|
||||
dependent_project_id: ['downloads'],
|
||||
project_status: ANALYTICS_DASHBOARD_STAT_ORDER,
|
||||
}
|
||||
|
||||
@@ -60,6 +72,8 @@ const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record<
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
user_id: 'user_id',
|
||||
dependent_project_download: 'dependent_project_download',
|
||||
version_id: 'version_id',
|
||||
loader: 'loader_type',
|
||||
game_version: 'game_version',
|
||||
@@ -74,9 +88,12 @@ const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record<
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
user_id: 'user_id',
|
||||
version_id: 'version_id',
|
||||
game_version: 'game_version',
|
||||
loader_type: 'loader_type',
|
||||
dependent_project_id: 'dependent_project_id',
|
||||
dependent_project_type: 'dependent_project_type',
|
||||
}
|
||||
|
||||
const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
|
||||
@@ -89,6 +106,8 @@ const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
user_id: 'user_id',
|
||||
dependent_project_download: null,
|
||||
version_id: 'version_id',
|
||||
loader: 'loader_type',
|
||||
game_version: 'game_version',
|
||||
@@ -286,6 +305,33 @@ export function getAnalyticsStatsForBreakdowns(
|
||||
return stats
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownsWithSharedStats(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
): AnalyticsSelectedBreakdowns {
|
||||
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
|
||||
const compatibleBreakdowns: AnalyticsSelectedBreakdowns = []
|
||||
let sharedStats: readonly AnalyticsDashboardStat[] | null = null
|
||||
|
||||
for (const breakdown of normalizedBreakdowns) {
|
||||
const breakdownStats = getAnalyticsStatsForBreakdown(breakdown)
|
||||
if (sharedStats === null) {
|
||||
compatibleBreakdowns.push(breakdown)
|
||||
sharedStats = breakdownStats
|
||||
continue
|
||||
}
|
||||
|
||||
const nextSharedStats = intersectAnalyticsStats(sharedStats, breakdownStats)
|
||||
if (nextSharedStats.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
compatibleBreakdowns.push(breakdown)
|
||||
sharedStats = nextSharedStats
|
||||
}
|
||||
|
||||
return compatibleBreakdowns
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForFilterCategory(
|
||||
category: AnalyticsQueryFilterCategory,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
@@ -331,12 +377,20 @@ export function getVisibleAnalyticsFilterCategoriesForState(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): readonly Exclude<AnalyticsQueryFilterCategory, 'project'>[] {
|
||||
return FILTER_VALUE_CATEGORIES.filter((category) =>
|
||||
haveAnalyticsStatOverlap(
|
||||
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
|
||||
return FILTER_VALUE_CATEGORIES.filter((category) => {
|
||||
if (
|
||||
category === 'dependent_project_type' &&
|
||||
!normalizedBreakdowns.includes('dependent_project_download')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return haveAnalyticsStatOverlap(
|
||||
getAnalyticsStatsForFilterScope(breakdowns, filters, category),
|
||||
getAnalyticsStatsForFilterCategory(category),
|
||||
),
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizeAnalyticsSelectedFilters(
|
||||
@@ -345,6 +399,7 @@ export function sanitizeAnalyticsSelectedFilters(
|
||||
): AnalyticsSelectedFilters {
|
||||
const nextFilters = cloneSelectedFilters(filters)
|
||||
let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
|
||||
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
|
||||
|
||||
for (const category of FILTER_VALUE_CATEGORIES) {
|
||||
if (filters[category].length === 0) {
|
||||
@@ -352,7 +407,11 @@ export function sanitizeAnalyticsSelectedFilters(
|
||||
}
|
||||
|
||||
const categoryStats = getAnalyticsStatsForFilterCategory(category)
|
||||
if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) {
|
||||
if (
|
||||
!haveAnalyticsStatOverlap(availableStats, categoryStats) ||
|
||||
(category === 'dependent_project_type' &&
|
||||
!normalizedBreakdowns.includes('dependent_project_download'))
|
||||
) {
|
||||
nextFilters[category] = []
|
||||
continue
|
||||
}
|
||||
@@ -371,9 +430,12 @@ export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): Analyti
|
||||
monetization: [...filters.monetization],
|
||||
user_agent: [...filters.user_agent],
|
||||
download_reason: [...filters.download_reason],
|
||||
user_id: [...filters.user_id],
|
||||
version_id: [...filters.version_id],
|
||||
game_version: [...filters.game_version],
|
||||
loader_type: [...filters.loader_type],
|
||||
dependent_project_id: [...filters.dependent_project_id],
|
||||
dependent_project_type: [...filters.dependent_project_type],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +518,7 @@ export function normalizeSelectedValues(
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(isProjectStatusFilterValue)
|
||||
}
|
||||
if (categoryKey === 'loader_type') {
|
||||
if (categoryKey === 'loader_type' || categoryKey === 'dependent_project_type') {
|
||||
return Array.from(
|
||||
new Set(
|
||||
selectedValues
|
||||
|
||||
Reference in New Issue
Block a user